From 5289cd3af1c1fc6d78557b4b5b892709221b7f1b Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 26 Mar 2024 18:00:09 +0100 Subject: [PATCH] Add floor support (#20187) * Add floor support * Update src/components/ha-area-floor-picker.ts Co-authored-by: Paul Bottein * Use different type for floor area picker * type --------- Co-authored-by: Paul Bottein --- gallery/src/pages/components/ha-form.ts | 3 + gallery/src/pages/components/ha-selector.ts | 3 + package.json | 2 +- src/components/ha-area-floor-picker.ts | 493 +++++++++++++++++ src/components/ha-area-picker.ts | 3 + src/components/ha-floor-picker.ts | 499 ++++++++++++++++++ src/components/ha-service-control.ts | 41 +- src/components/ha-target-picker.ts | 115 +++- src/data/area_registry.ts | 2 + src/data/floor_registry.ts | 133 +++++ src/data/selector.ts | 26 + .../areas/dialog-area-registry-detail.ts | 18 + .../areas/dialog-floor-registry-detail.ts | 216 ++++++++ .../config/areas/ha-config-area-page.ts | 7 +- .../config/areas/ha-config-areas-dashboard.ts | 138 ++++- .../show-dialog-floor-registry-detail.ts | 27 + src/translations/en.json | 41 +- yarn.lock | 10 +- 18 files changed, 1724 insertions(+), 53 deletions(-) create mode 100644 src/components/ha-area-floor-picker.ts create mode 100644 src/components/ha-floor-picker.ts create mode 100644 src/data/floor_registry.ts create mode 100644 src/panels/config/areas/dialog-floor-registry-detail.ts create mode 100644 src/panels/config/areas/show-dialog-floor-registry-detail.ts diff --git a/gallery/src/pages/components/ha-form.ts b/gallery/src/pages/components/ha-form.ts index 391b01c210..c4172d2600 100644 --- a/gallery/src/pages/components/ha-form.ts +++ b/gallery/src/pages/components/ha-form.ts @@ -101,6 +101,7 @@ const DEVICES = [ const AREAS: AreaRegistryEntry[] = [ { area_id: "backyard", + floor_id: null, name: "Backyard", icon: null, picture: null, @@ -108,6 +109,7 @@ const AREAS: AreaRegistryEntry[] = [ }, { area_id: "bedroom", + floor_id: null, name: "Bedroom", icon: "mdi:bed", picture: null, @@ -115,6 +117,7 @@ const AREAS: AreaRegistryEntry[] = [ }, { area_id: "livingroom", + floor_id: null, name: "Livingroom", icon: "mdi:sofa", picture: null, diff --git a/gallery/src/pages/components/ha-selector.ts b/gallery/src/pages/components/ha-selector.ts index fceab71c29..91fa1afd84 100644 --- a/gallery/src/pages/components/ha-selector.ts +++ b/gallery/src/pages/components/ha-selector.ts @@ -97,6 +97,7 @@ const DEVICES = [ const AREAS: AreaRegistryEntry[] = [ { area_id: "backyard", + floor_id: null, name: "Backyard", icon: null, picture: null, @@ -104,6 +105,7 @@ const AREAS: AreaRegistryEntry[] = [ }, { area_id: "bedroom", + floor_id: null, name: "Bedroom", icon: "mdi:bed", picture: null, @@ -111,6 +113,7 @@ const AREAS: AreaRegistryEntry[] = [ }, { area_id: "livingroom", + floor_id: null, name: "Livingroom", icon: "mdi:sofa", picture: null, diff --git a/package.json b/package.json index 004dca7b6c..d0f7aca993 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,7 @@ "fuse.js": "7.0.0", "google-timezones-json": "1.2.0", "hls.js": "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch", - "home-assistant-js-websocket": "9.1.0", + "home-assistant-js-websocket": "9.2.1", "idb-keyval": "6.2.1", "intl-messageformat": "10.5.11", "js-yaml": "4.1.0", diff --git a/src/components/ha-area-floor-picker.ts b/src/components/ha-area-floor-picker.ts new file mode 100644 index 0000000000..f5a7f59356 --- /dev/null +++ b/src/components/ha-area-floor-picker.ts @@ -0,0 +1,493 @@ +import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; +import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; +import { LitElement, PropertyValues, TemplateResult, html, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../common/dom/fire_event"; +import { computeDomain } from "../common/entity/compute_domain"; +import { + ScorableTextItem, + fuzzyFilterSort, +} from "../common/string/filter/sequence-matching"; +import { AreaRegistryEntry } from "../data/area_registry"; +import { + DeviceEntityDisplayLookup, + DeviceRegistryEntry, + getDeviceEntityDisplayLookup, +} from "../data/device_registry"; +import { EntityRegistryDisplayEntry } from "../data/entity_registry"; +import { + FloorRegistryEntry, + getFloorAreaLookup, + subscribeFloorRegistry, +} from "../data/floor_registry"; +import { SubscribeMixin } from "../mixins/subscribe-mixin"; +import { HomeAssistant, ValueChangedEvent } from "../types"; +import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; +import "./ha-combo-box"; +import type { HaComboBox } from "./ha-combo-box"; +import "./ha-icon-button"; +import "./ha-list-item"; +import "./ha-svg-icon"; +import { stringCompare } from "../common/string/compare"; + +type ScorableAreaFloorEntry = ScorableTextItem & FloorAreaEntry; + +interface FloorAreaEntry { + id: string | null; + name: string; + icon: string | null; + strings: string[]; + type: "floor" | "area"; +} + +const rowRenderer: ComboBoxLitRenderer = (item) => + item.type === "floor" + ? html` + ${item.icon + ? html`` + : nothing} + ${item.name} + ` + : html` + ${item.icon + ? html`` + : nothing} + ${item.name} + `; + +@customElement("ha-area-floor-picker") +export class HaAreaFloorPicker extends SubscribeMixin(LitElement) { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public label?: string; + + @property() public value?: string; + + @property() public helper?: string; + + @property() public placeholder?: string; + + /** + * 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[]; + + /** + * List of areas to be excluded. + * @type {Array} + * @attr exclude-areas + */ + @property({ type: Array, attribute: "exclude-areas" }) + public excludeAreas?: string[]; + + /** + * List of floors to be excluded. + * @type {Array} + * @attr exclude-floors + */ + @property({ type: Array, attribute: "exclude-floors" }) + public excludeFloors?: string[]; + + @property({ attribute: false }) + public deviceFilter?: HaDevicePickerDeviceFilterFunc; + + @property({ attribute: false }) + public entityFilter?: (entity: HassEntity) => boolean; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = false; + + @state() private _floors?: FloorRegistryEntry[]; + + @state() private _opened?: boolean; + + @query("ha-combo-box", true) public comboBox!: HaComboBox; + + private _init = false; + + protected hassSubscribe(): (UnsubscribeFunc | Promise)[] { + return [ + subscribeFloorRegistry(this.hass.connection, (floors) => { + this._floors = floors; + }), + ]; + } + + public async open() { + await this.updateComplete; + await this.comboBox?.open(); + } + + public async focus() { + await this.updateComplete; + await this.comboBox?.focus(); + } + + private _getAreas = memoizeOne( + ( + floors: FloorRegistryEntry[], + areas: AreaRegistryEntry[], + devices: DeviceRegistryEntry[], + entities: EntityRegistryDisplayEntry[], + includeDomains: this["includeDomains"], + excludeDomains: this["excludeDomains"], + includeDeviceClasses: this["includeDeviceClasses"], + deviceFilter: this["deviceFilter"], + entityFilter: this["entityFilter"], + excludeAreas: this["excludeAreas"], + excludeFloors: this["excludeFloors"] + ): FloorAreaEntry[] => { + if (!areas.length && !floors.length) { + return [ + { + id: "no_areas", + type: "area", + name: this.hass.localize("ui.components.area-picker.no_areas"), + icon: null, + strings: [], + }, + ]; + } + + let deviceEntityLookup: DeviceEntityDisplayLookup = {}; + let inputDevices: DeviceRegistryEntry[] | undefined; + let inputEntities: EntityRegistryDisplayEntry[] | undefined; + + if ( + includeDomains || + excludeDomains || + includeDeviceClasses || + deviceFilter || + entityFilter + ) { + deviceEntityLookup = getDeviceEntityDisplayLookup(entities); + inputDevices = devices; + 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) { + 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 entityFilter(stateObj); + }); + }); + inputEntities = inputEntities!.filter((entity) => { + const stateObj = this.hass.states[entity.entity_id]; + if (!stateObj) { + return false; + } + return entityFilter!(stateObj); + }); + } + } + + 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 = outputAreas.filter((area) => + areaIds!.includes(area.area_id) + ); + } + + if (excludeAreas) { + outputAreas = outputAreas.filter( + (area) => !excludeAreas!.includes(area.area_id) + ); + } + + if (excludeFloors) { + outputAreas = outputAreas.filter( + (area) => !area.floor_id || !excludeFloors!.includes(area.floor_id) + ); + } + + if (!outputAreas.length) { + return [ + { + id: "no_areas", + type: "area", + name: this.hass.localize("ui.components.area-picker.no_match"), + icon: null, + strings: [], + }, + ]; + } + + const floorAreaLookup = getFloorAreaLookup(outputAreas); + const unassisgnedAreas = Object.values(outputAreas).filter( + (area) => !area.floor_id || !floorAreaLookup[area.floor_id] + ); + + // @ts-ignore + const floorAreaEntries: Array< + [FloorRegistryEntry | undefined, AreaRegistryEntry[]] + > = Object.entries(floorAreaLookup) + .map(([floorId, floorAreas]) => { + const floor = floors.find((fl) => fl.floor_id === floorId)!; + return [floor, floorAreas] as const; + }) + .sort(([floorA], [floorB]) => { + if (floorA.level !== floorB.level) { + return (floorA.level ?? 0) - (floorB.level ?? 0); + } + return stringCompare(floorA.name, floorB.name); + }); + + const output: FloorAreaEntry[] = []; + + floorAreaEntries.forEach(([floor, floorAreas]) => { + if (floor) { + output.push({ + id: floor.floor_id, + type: "floor", + name: floor.name, + icon: floor.icon, + strings: [floor.floor_id, ...floor.aliases, floor.name], + }); + } + output.push( + ...floorAreas.map((area) => ({ + id: area.area_id, + type: "area" as const, + name: area.name, + icon: area.icon, + strings: [area.area_id, ...area.aliases, area.name], + })) + ); + }); + + if (!output.length && !unassisgnedAreas.length) { + output.push({ + id: "no_areas", + type: "area", + name: this.hass.localize( + "ui.components.area-picker.unassigned_areas" + ), + icon: null, + strings: [], + }); + } + + output.push( + ...unassisgnedAreas.map((area) => ({ + id: area.area_id, + type: "area" as const, + name: area.name, + icon: area.icon, + strings: [area.area_id, ...area.aliases, area.name], + })) + ); + + return output; + } + ); + + protected updated(changedProps: PropertyValues) { + if ( + (!this._init && this.hass && this._floors) || + (this._init && changedProps.has("_opened") && this._opened) + ) { + this._init = true; + const areas = this._getAreas( + this._floors!, + Object.values(this.hass.areas), + Object.values(this.hass.devices), + Object.values(this.hass.entities), + this.includeDomains, + this.excludeDomains, + this.includeDeviceClasses, + this.deviceFilter, + this.entityFilter, + this.excludeAreas, + this.excludeFloors + ); + this.comboBox.items = areas; + this.comboBox.filteredItems = areas; + } + } + + protected render(): TemplateResult { + return html` + + + `; + } + + private _filterChanged(ev: CustomEvent): void { + const target = ev.target as HaComboBox; + const filterString = ev.detail.value; + if (!filterString) { + this.comboBox.filteredItems = this.comboBox.items; + return; + } + + const filteredItems = fuzzyFilterSort( + filterString, + target.items || [] + ); + + this.comboBox.filteredItems = filteredItems; + } + + private get _value() { + return this.value || ""; + } + + private _openedChanged(ev: ValueChangedEvent) { + this._opened = ev.detail.value; + } + + private async _areaChanged(ev: ValueChangedEvent) { + ev.stopPropagation(); + const newValue = ev.detail.value; + + if (newValue === "no_areas") { + return; + } + + const selected = this.comboBox.selectedItem; + + fireEvent(this, "value-changed", { + value: { + id: selected.id, + type: selected.type, + }, + }); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-area-floor-picker": HaAreaFloorPicker; + } +} diff --git a/src/components/ha-area-picker.ts b/src/components/ha-area-picker.ts index b68b08039a..2cfa3596d1 100644 --- a/src/components/ha-area-picker.ts +++ b/src/components/ha-area-picker.ts @@ -137,6 +137,7 @@ export class HaAreaPicker extends LitElement { return [ { area_id: "no_areas", + floor_id: null, name: this.hass.localize("ui.components.area-picker.no_areas"), picture: null, icon: null, @@ -282,6 +283,7 @@ export class HaAreaPicker extends LitElement { outputAreas = [ { area_id: "no_areas", + floor_id: null, name: this.hass.localize("ui.components.area-picker.no_match"), picture: null, icon: null, @@ -296,6 +298,7 @@ export class HaAreaPicker extends LitElement { ...outputAreas, { area_id: "add_new", + floor_id: null, name: this.hass.localize("ui.components.area-picker.add_new"), picture: null, icon: "mdi:plus", diff --git a/src/components/ha-floor-picker.ts b/src/components/ha-floor-picker.ts new file mode 100644 index 0000000000..b344057543 --- /dev/null +++ b/src/components/ha-floor-picker.ts @@ -0,0 +1,499 @@ +import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; +import { HassEntity, UnsubscribeFunc } from "home-assistant-js-websocket"; +import { html, LitElement, nothing, PropertyValues, TemplateResult } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../common/dom/fire_event"; +import { computeDomain } from "../common/entity/compute_domain"; +import { + fuzzyFilterSort, + ScorableTextItem, +} from "../common/string/filter/sequence-matching"; +import { AreaRegistryEntry } from "../data/area_registry"; +import { + DeviceEntityDisplayLookup, + DeviceRegistryEntry, + getDeviceEntityDisplayLookup, +} from "../data/device_registry"; +import { EntityRegistryDisplayEntry } from "../data/entity_registry"; +import { + showAlertDialog, + showPromptDialog, +} from "../dialogs/generic/show-dialog-box"; +import { HomeAssistant, ValueChangedEvent } from "../types"; +import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; +import "./ha-combo-box"; +import type { HaComboBox } from "./ha-combo-box"; +import "./ha-icon-button"; +import "./ha-list-item"; +import "./ha-svg-icon"; +import { SubscribeMixin } from "../mixins/subscribe-mixin"; +import { + createFloorRegistryEntry, + FloorRegistryEntry, + getFloorAreaLookup, + subscribeFloorRegistry, +} from "../data/floor_registry"; + +type ScorableFloorRegistryEntry = ScorableTextItem & FloorRegistryEntry; + +const rowRenderer: ComboBoxLitRenderer = (item) => + html` + ${item.icon + ? html`` + : nothing} + ${item.name} + `; + +@customElement("ha-floor-picker") +export class HaFloorPicker extends SubscribeMixin(LitElement) { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public label?: string; + + @property() 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[]; + + /** + * List of floors to be excluded. + * @type {Array} + * @attr exclude-floors + */ + @property({ type: Array, attribute: "exclude-floor" }) + public excludeFloors?: string[]; + + @property({ attribute: false }) + public deviceFilter?: HaDevicePickerDeviceFilterFunc; + + @property({ attribute: false }) + public entityFilter?: (entity: HassEntity) => boolean; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = false; + + @state() private _opened?: boolean; + + @state() private _floors?: FloorRegistryEntry[]; + + @query("ha-combo-box", true) public comboBox!: HaComboBox; + + private _suggestion?: string; + + private _init = false; + + public async open() { + await this.updateComplete; + await this.comboBox?.open(); + } + + public async focus() { + await this.updateComplete; + await this.comboBox?.focus(); + } + + protected hassSubscribe(): (UnsubscribeFunc | Promise)[] { + return [ + subscribeFloorRegistry(this.hass.connection, (floors) => { + this._floors = floors; + }), + ]; + } + + private _getFloors = memoizeOne( + ( + floors: FloorRegistryEntry[], + areas: AreaRegistryEntry[], + devices: DeviceRegistryEntry[], + entities: EntityRegistryDisplayEntry[], + includeDomains: this["includeDomains"], + excludeDomains: this["excludeDomains"], + includeDeviceClasses: this["includeDeviceClasses"], + deviceFilter: this["deviceFilter"], + entityFilter: this["entityFilter"], + noAdd: this["noAdd"], + excludeFloors: this["excludeFloors"] + ): FloorRegistryEntry[] => { + if (!floors.length) { + return [ + { + floor_id: "no_floors", + name: this.hass.localize("ui.components.floor-picker.no_floors"), + icon: null, + level: 0, + aliases: [], + }, + ]; + } + + let deviceEntityLookup: DeviceEntityDisplayLookup = {}; + let inputDevices: DeviceRegistryEntry[] | undefined; + let inputEntities: EntityRegistryDisplayEntry[] | undefined; + + if ( + includeDomains || + excludeDomains || + includeDeviceClasses || + deviceFilter || + entityFilter + ) { + deviceEntityLookup = getDeviceEntityDisplayLookup(entities); + inputDevices = devices; + 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) { + 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 entityFilter(stateObj); + }); + }); + inputEntities = inputEntities!.filter((entity) => { + const stateObj = this.hass.states[entity.entity_id]; + if (!stateObj) { + return false; + } + return entityFilter!(stateObj); + }); + } + } + + let outputFloors = floors; + + 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) { + const floorAreaLookup = getFloorAreaLookup(areas); + outputFloors = outputFloors.filter((floor) => + floorAreaLookup[floor.floor_id].some((area) => + areaIds!.includes(area.area_id) + ) + ); + } + + if (excludeFloors) { + outputFloors = outputFloors.filter( + (floor) => !excludeFloors!.includes(floor.floor_id) + ); + } + + if (!outputFloors.length) { + outputFloors = [ + { + floor_id: "no_floors", + name: this.hass.localize("ui.components.floor-picker.no_match"), + icon: null, + level: 0, + aliases: [], + }, + ]; + } + + return noAdd + ? outputFloors + : [ + ...outputFloors, + { + floor_id: "add_new", + name: this.hass.localize("ui.components.floor-picker.add_new"), + icon: "mdi:plus", + level: 0, + aliases: [], + }, + ]; + } + ); + + protected updated(changedProps: PropertyValues) { + if ( + (!this._init && this.hass && this._floors) || + (this._init && changedProps.has("_opened") && this._opened) + ) { + this._init = true; + const floors = this._getFloors( + this._floors!, + Object.values(this.hass.areas), + Object.values(this.hass.devices), + Object.values(this.hass.entities), + this.includeDomains, + this.excludeDomains, + this.includeDeviceClasses, + this.deviceFilter, + this.entityFilter, + this.noAdd, + this.excludeFloors + ).map((floor) => ({ + ...floor, + strings: [floor.floor_id, floor.name], // ...floor.aliases + })); + this.comboBox.items = floors; + this.comboBox.filteredItems = floors; + } + } + + protected render(): TemplateResult { + return html` + floor.floor_id === this.placeholder) + ?.name + : undefined} + .renderer=${rowRenderer} + @filter-changed=${this._filterChanged} + @opened-changed=${this._openedChanged} + @value-changed=${this._floorChanged} + > + + `; + } + + private _filterChanged(ev: CustomEvent): void { + const target = ev.target as HaComboBox; + const filterString = ev.detail.value; + if (!filterString) { + this.comboBox.filteredItems = this.comboBox.items; + return; + } + + const filteredItems = fuzzyFilterSort( + filterString, + target.items || [] + ); + if (!this.noAdd && filteredItems?.length === 0) { + this._suggestion = filterString; + this.comboBox.filteredItems = [ + { + floor_id: "add_new_suggestion", + name: this.hass.localize( + "ui.components.floor-picker.add_new_sugestion", + { name: this._suggestion } + ), + picture: null, + }, + ]; + } else { + this.comboBox.filteredItems = filteredItems; + } + } + + private get _value() { + return this.value || ""; + } + + private _openedChanged(ev: ValueChangedEvent) { + this._opened = ev.detail.value; + } + + private _floorChanged(ev: ValueChangedEvent) { + ev.stopPropagation(); + let newValue = ev.detail.value; + + if (newValue === "no_floors") { + newValue = ""; + } + + if (!["add_new_suggestion", "add_new"].includes(newValue)) { + if (newValue !== this._value) { + this._setValue(newValue); + } + return; + } + + (ev.target as any).value = this._value; + showPromptDialog(this, { + title: this.hass.localize("ui.components.floor-picker.add_dialog.title"), + text: this.hass.localize("ui.components.floor-picker.add_dialog.text"), + confirmText: this.hass.localize( + "ui.components.floor-picker.add_dialog.add" + ), + inputLabel: this.hass.localize( + "ui.components.floor-picker.add_dialog.name" + ), + defaultValue: + newValue === "add_new_suggestion" ? this._suggestion : undefined, + confirm: async (name) => { + if (!name) { + return; + } + try { + const floor = await createFloorRegistryEntry(this.hass, { + name, + }); + const floors = [...this._floors!, floor]; + this.comboBox.filteredItems = this._getFloors( + floors, + Object.values(this.hass.areas)!, + Object.values(this.hass.devices)!, + Object.values(this.hass.entities)!, + this.includeDomains, + this.excludeDomains, + this.includeDeviceClasses, + this.deviceFilter, + this.entityFilter, + this.noAdd, + this.excludeFloors + ); + await this.updateComplete; + await this.comboBox.updateComplete; + this._setValue(floor.floor_id); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.components.floor-picker.add_dialog.failed_create_floor" + ), + text: err.message, + }); + } + }, + cancel: () => { + this._setValue(undefined); + this._suggestion = undefined; + this.comboBox.setInputValue(""); + }, + }); + } + + private _setValue(value?: string) { + this.value = value; + setTimeout(() => { + fireEvent(this, "value-changed", { value }); + fireEvent(this, "change"); + }, 0); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-floor-picker": HaFloorPicker; + } +} diff --git a/src/components/ha-service-control.ts b/src/components/ha-service-control.ts index 778216f792..e54423b9b6 100644 --- a/src/components/ha-service-control.ts +++ b/src/components/ha-service-control.ts @@ -30,6 +30,7 @@ import { entityMeetsTargetSelector, expandAreaTarget, expandDeviceTarget, + expandFloorTarget, Selector, } from "../data/selector"; import { HomeAssistant, ValueChangedEvent } from "../types"; @@ -58,20 +59,12 @@ const showOptionalToggle = (field) => !("boolean" in field.selector && field.default); interface ExtHassService extends Omit { - fields: { - key: string; - name?: string; - description: string; - required?: boolean; - advanced?: boolean; - default?: any; - example?: any; - filter?: { - supported_features?: number[]; - attribute?: Record; - }; - selector?: Selector; - }[]; + fields: Array< + Omit & { + key: string; + selector?: Selector; + } + >; hasSelector: string[]; } @@ -275,10 +268,24 @@ export class HaServiceControl extends LitElement { ensureArray( value?.target?.device_id || value?.data?.device_id )?.slice() || []; - const targetAreas = ensureArray( - value?.target?.area_id || value?.data?.area_id + const targetAreas = + ensureArray(value?.target?.area_id || value?.data?.area_id)?.slice() || + []; + const targetFloors = ensureArray( + value?.target?.floor_id || value?.data?.floor_id )?.slice(); - if (targetAreas) { + if (targetFloors) { + targetFloors.forEach((floorId) => { + const expanded = expandFloorTarget( + this.hass, + floorId, + this.hass.areas, + targetSelector + ); + targetAreas.push(...expanded.areas); + }); + } + if (targetAreas.length) { targetAreas.forEach((areaId) => { const expanded = expandAreaTarget( this.hass, diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index 4030e8ff34..d2e8f99c1e 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -6,12 +6,17 @@ import "@material/mwc-menu/mwc-menu-surface"; import { mdiClose, mdiDevices, + mdiFloorPlan, mdiPlus, mdiSofa, mdiUnfoldMoreVertical, } from "@mdi/js"; import { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light"; -import { HassEntity, HassServiceTarget } from "home-assistant-js-websocket"; +import { + HassEntity, + HassServiceTarget, + UnsubscribeFunc, +} from "home-assistant-js-websocket"; import { css, CSSResultGroup, html, LitElement, nothing, unsafeCSS } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; @@ -31,13 +36,19 @@ import "./device/ha-device-picker"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; import "./entity/ha-entity-picker"; import type { HaEntityPickerEntityFilterFunc } from "./entity/ha-entity-picker"; -import "./ha-area-picker"; +import "./ha-area-floor-picker"; import "./ha-icon-button"; import "./ha-input-helper-text"; import "./ha-svg-icon"; +import { SubscribeMixin } from "../mixins/subscribe-mixin"; +import { + FloorRegistryEntry, + subscribeFloorRegistry, +} from "../data/floor_registry"; +import { AreaRegistryEntry } from "../data/area_registry"; @customElement("ha-target-picker") -export class HaTargetPicker extends LitElement { +export class HaTargetPicker extends SubscribeMixin(LitElement) { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public value?: HassServiceTarget; @@ -78,8 +89,18 @@ export class HaTargetPicker extends LitElement { @query(".add-container", true) private _addContainer?: HTMLDivElement; + @state() private _floors?: FloorRegistryEntry[]; + private _opened = false; + protected hassSubscribe(): (UnsubscribeFunc | Promise)[] { + return [ + subscribeFloorRegistry(this.hass.connection, (floors) => { + this._floors = floors; + }), + ]; + } + protected render() { if (this.addOnTop) { return html` ${this._renderChips()} ${this._renderItems()} `; @@ -90,6 +111,21 @@ export class HaTargetPicker extends LitElement { private _renderItems() { return html`
+ ${this.value?.floor_id + ? ensureArray(this.value.floor_id).map((floor_id) => { + const floor = this._floors?.find( + (flr) => flr.floor_id === floor_id + ); + return this._renderChip( + "floor_id", + floor_id, + floor?.name || floor_id, + undefined, + floor?.icon, + mdiFloorPlan + ); + }) + : ""} ${this.value?.area_id ? ensureArray(this.value.area_id).map((area_id) => { const area = this.hass.areas![area_id]; @@ -207,7 +243,7 @@ export class HaTargetPicker extends LitElement { } private _renderChip( - type: "area_id" | "device_id" | "entity_id", + type: "floor_id" | "area_id" | "device_id" | "entity_id", id: string, name: string, entityState?: HassEntity, @@ -296,7 +332,7 @@ export class HaTargetPicker extends LitElement { @input=${stopPropagation} >${this._addMode === "area_id" ? html` - + > ` : this._addMode === "device_id" ? html` @@ -356,18 +393,24 @@ export class HaTargetPicker extends LitElement { if (!ev.detail.value) { return; } - const value = ev.detail.value; + let value = ev.detail.value; const target = ev.currentTarget; + let type = target.type; - if (target.type === "entity_id" && !isValidEntityId(value)) { + if (type === "entity_id" && !isValidEntityId(value)) { return; } + if (type === "area_id") { + value = ev.detail.value.id; + type = `${ev.detail.value.type}_id`; + } + target.value = ""; if ( this.value && - this.value[target.type] && - ensureArray(this.value[target.type]).includes(value) + this.value[type] && + ensureArray(this.value[type]).includes(value) ) { return; } @@ -375,19 +418,31 @@ export class HaTargetPicker extends LitElement { value: this.value ? { ...this.value, - [target.type]: this.value[target.type] - ? [...ensureArray(this.value[target.type]), value] + [type]: this.value[type] + ? [...ensureArray(this.value[type]), value] : value, } - : { [target.type]: value }, + : { [type]: value }, }); } private _handleExpand(ev) { const target = ev.currentTarget as any; + const newAreas: string[] = []; const newDevices: string[] = []; const newEntities: string[] = []; - if (target.type === "area_id") { + + if (target.type === "floor_id") { + Object.values(this.hass.areas).forEach((area) => { + if ( + area.floor_id === target.id && + !this.value!.area_id?.includes(area.area_id) && + this._areaMeetsFilter(area) + ) { + newAreas.push(area.area_id); + } + }); + } else if (target.type === "area_id") { Object.values(this.hass.devices).forEach((device) => { if ( device.area_id === target.id && @@ -426,6 +481,9 @@ export class HaTargetPicker extends LitElement { if (newDevices.length) { value = this._addItems(value, "device_id", newDevices); } + if (newAreas.length) { + value = this._addItems(value, "area_id", newAreas); + } value = this._removeItem(value, target.type, target.id); fireEvent(this, "value-changed", { value }); } @@ -495,6 +553,26 @@ export class HaTargetPicker extends LitElement { ev.preventDefault(); } + private _areaMeetsFilter(area: AreaRegistryEntry): boolean { + const areaDevices = Object.values(this.hass.devices).filter( + (device) => device.area_id === area.area_id + ); + + if (areaDevices.some((device) => this._deviceMeetsFilter(device))) { + return true; + } + + const areaEntities = Object.values(this.hass.entities).filter( + (entity) => entity.area_id === area.area_id + ); + + if (areaEntities.some((entity) => this._entityRegMeetsFilter(entity))) { + return true; + } + + return false; + } + private _deviceMeetsFilter(device: DeviceRegistryEntry): boolean { const devEntities = Object.values(this.hass.entities).filter( (entity) => entity.device_id === device.id @@ -651,12 +729,15 @@ export class HaTargetPicker extends LitElement { margin-inline-end: 0; margin-inline-start: initial; } - .mdc-chip.area_id:not(.add) { + .mdc-chip.area_id:not(.add), + .mdc-chip.floor_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 { + .mdc-chip.area_id.add, + .mdc-chip.floor_id:not(.add) .mdc-chip__icon--leading, + .mdc-chip.floor_id.add { background: #fed6a4; } .mdc-chip.device_id:not(.add) { @@ -690,7 +771,7 @@ export class HaTargetPicker extends LitElement { } ha-entity-picker, ha-device-picker, - ha-area-picker { + ha-area-floor-picker { display: block; width: 100%; } diff --git a/src/data/area_registry.ts b/src/data/area_registry.ts index fcdaaf1bc1..69af2ec7b2 100644 --- a/src/data/area_registry.ts +++ b/src/data/area_registry.ts @@ -7,6 +7,7 @@ export { subscribeAreaRegistry } from "./ws-area_registry"; export interface AreaRegistryEntry { area_id: string; + floor_id: string | null; name: string; picture: string | null; icon: string | null; @@ -23,6 +24,7 @@ export interface AreaDeviceLookup { export interface AreaRegistryEntryMutableParams { name: string; + floor_id?: string | null; picture?: string | null; icon?: string | null; aliases?: string[]; diff --git a/src/data/floor_registry.ts b/src/data/floor_registry.ts new file mode 100644 index 0000000000..3400af6c99 --- /dev/null +++ b/src/data/floor_registry.ts @@ -0,0 +1,133 @@ +import { Connection, createCollection } from "home-assistant-js-websocket"; +import { Store } from "home-assistant-js-websocket/dist/store"; +import { stringCompare } from "../common/string/compare"; +import { HomeAssistant } from "../types"; +import { AreaRegistryEntry } from "./area_registry"; +import { debounce } from "../common/util/debounce"; + +export { subscribeAreaRegistry } from "./ws-area_registry"; + +export interface FloorRegistryEntry { + floor_id: string; + name: string; + level: number; + icon: string | null; + aliases: string[]; +} + +export interface FloorAreaLookup { + [floorId: string]: AreaRegistryEntry[]; +} + +export interface FloorRegistryEntryMutableParams { + name: string; + level?: number; + icon?: string | null; + aliases?: string[]; +} + +const fetchFloorRegistry = (conn: Connection) => + conn + .sendMessagePromise({ + type: "config/floor_registry/list", + }) + .then((floors) => + (floors as FloorRegistryEntry[]).sort((ent1, ent2) => { + if (ent1.level !== ent2.level) { + return (ent1.level ?? 0) - (ent2.level ?? 0); + } + return stringCompare(ent1.name, ent2.name); + }) + ); + +const subscribeFloorRegistryUpdates = ( + conn: Connection, + store: Store +) => + conn.subscribeEvents( + debounce( + () => + fetchFloorRegistry(conn).then((areas: FloorRegistryEntry[]) => + store.setState(areas, true) + ), + 500, + true + ), + "floor_registry_updated" + ); + +export const subscribeFloorRegistry = ( + conn: Connection, + onChange: (floors: FloorRegistryEntry[]) => void +) => + createCollection( + "_floorRegistry", + fetchFloorRegistry, + subscribeFloorRegistryUpdates, + conn, + onChange + ); + +export const createFloorRegistryEntry = ( + hass: HomeAssistant, + values: FloorRegistryEntryMutableParams +) => + hass.callWS({ + type: "config/floor_registry/create", + ...values, + }); + +export const updateFloorRegistryEntry = ( + hass: HomeAssistant, + floorId: string, + updates: Partial +) => + hass.callWS({ + type: "config/floor_registry/update", + floor_id: floorId, + ...updates, + }); + +export const deleteFloorRegistryEntry = ( + hass: HomeAssistant, + floorId: string +) => + hass.callWS({ + type: "config/floor_registry/delete", + floor_id: floorId, + }); + +export const getFloorAreaLookup = ( + areas: AreaRegistryEntry[] +): FloorAreaLookup => { + const floorAreaLookup: FloorAreaLookup = {}; + for (const area of areas) { + if (!area.floor_id) { + continue; + } + if (!(area.floor_id in floorAreaLookup)) { + floorAreaLookup[area.floor_id] = []; + } + floorAreaLookup[area.floor_id].push(area); + } + return floorAreaLookup; +}; + +export const floorCompare = + (entries?: FloorRegistryEntry[], order?: string[]) => + (a: string, b: string) => { + const indexA = order ? order.indexOf(a) : -1; + const indexB = order ? order.indexOf(b) : -1; + if (indexA === -1 && indexB === -1) { + const nameA = entries?.[a]?.name ?? a; + const nameB = entries?.[b]?.name ?? b; + return stringCompare(nameA, nameB); + } + if (indexA === -1) { + return 1; + } + if (indexB === -1) { + return -1; + } + return indexA - indexB; + }; diff --git a/src/data/selector.ts b/src/data/selector.ts index d4de771854..77be0fd1d0 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -424,6 +424,32 @@ export interface UiColorSelector { ui_color: {} | null; } +export const expandFloorTarget = ( + hass: HomeAssistant, + floorId: string, + areas: HomeAssistant["areas"], + targetSelector: TargetSelector, + entitySources?: EntitySources +) => { + const newAreas: string[] = []; + Object.values(areas).forEach((area) => { + if ( + area.floor_id === floorId && + areaMeetsTargetSelector( + hass, + hass.entities, + hass.devices, + area.area_id, + targetSelector, + entitySources + ) + ) { + newAreas.push(area.area_id); + } + }); + return { areas: newAreas }; +}; + export const expandAreaTarget = ( hass: HomeAssistant, areaId: string, diff --git a/src/panels/config/areas/dialog-area-registry-detail.ts b/src/panels/config/areas/dialog-area-registry-detail.ts index fafeecd787..31d313309a 100644 --- a/src/panels/config/areas/dialog-area-registry-detail.ts +++ b/src/panels/config/areas/dialog-area-registry-detail.ts @@ -10,6 +10,7 @@ import "../../../components/ha-picture-upload"; import type { HaPictureUpload } from "../../../components/ha-picture-upload"; import "../../../components/ha-settings-row"; import "../../../components/ha-icon-picker"; +import "../../../components/ha-floor-picker"; import "../../../components/ha-textfield"; import { AreaRegistryEntryMutableParams } from "../../../data/area_registry"; import { CropOptions } from "../../../dialogs/image-cropper-dialog/show-image-cropper-dialog"; @@ -35,6 +36,8 @@ class DialogAreaDetail extends LitElement { @state() private _icon!: string | null; + @state() private _floor!: string | null; + @state() private _error?: string; @state() private _params?: AreaRegistryDetailDialogParams; @@ -50,6 +53,7 @@ class DialogAreaDetail extends LitElement { this._aliases = this._params.entry ? this._params.entry.aliases : []; this._picture = this._params.entry?.picture || null; this._icon = this._params.entry?.icon || null; + this._floor = this._params.entry?.floor_id || null; await this.updateComplete; } @@ -112,6 +116,13 @@ class DialogAreaDetail extends LitElement { .label=${this.hass.localize("ui.panel.config.areas.editor.icon")} > + + { + this._params = params; + this._error = undefined; + this._name = this._params.entry ? this._params.entry.name : ""; + this._aliases = this._params.entry?.aliases || []; + this._icon = this._params.entry?.icon || null; + this._level = this._params.entry?.level ?? 0; + await this.updateComplete; + } + + public closeDialog(): void { + this._error = ""; + this._params = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render() { + if (!this._params) { + return nothing; + } + const entry = this._params.entry; + const nameInvalid = !this._isNameValid(); + return html` + +
+ ${this._error + ? html`${this._error}` + : ""} +
+ ${entry + ? html` + + + ${this.hass.localize( + "ui.panel.config.floors.editor.floor_id" + )} + + ${entry.floor_id} + + ` + : nothing} + + + + + + + +

+ ${this.hass.localize( + "ui.panel.config.floors.editor.aliases_section" + )} +

+ +

+ ${this.hass.localize( + "ui.panel.config.floors.editor.aliases_description" + )} +

+ +
+
+ + ${this.hass.localize("ui.common.cancel")} + + + ${entry + ? this.hass.localize("ui.common.save") + : this.hass.localize("ui.common.add")} + +
+ `; + } + + private _isNameValid() { + return this._name.trim() !== ""; + } + + private _nameChanged(ev) { + this._error = undefined; + this._name = ev.target.value; + } + + private _levelChanged(ev) { + this._error = undefined; + this._level = Number(ev.target.value); + } + + private _iconChanged(ev) { + this._error = undefined; + this._icon = ev.detail.value; + } + + private async _updateEntry() { + this._submitting = true; + const create = !this._params!.entry; + try { + const values: FloorRegistryEntryMutableParams = { + name: this._name.trim(), + icon: this._icon || (create ? undefined : null), + level: this._level, + aliases: this._aliases, + }; + if (create) { + await this._params!.createEntry!(values); + } else { + await this._params!.updateEntry!(values); + } + this.closeDialog(); + } catch (err: any) { + this._error = + err.message || + this.hass.localize("ui.panel.config.floors.editor.unknown_error"); + } finally { + this._submitting = false; + } + } + + private _aliasesChanged(ev: CustomEvent): void { + this._aliases = ev.detail.value; + } + + static get styles(): CSSResultGroup { + return [ + haStyleDialog, + css` + ha-textfield { + display: block; + margin-bottom: 16px; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-floor-registry-detail": DialogFloorDetail; + } +} + +customElements.define("dialog-floor-registry-detail", DialogFloorDetail); diff --git a/src/panels/config/areas/ha-config-area-page.ts b/src/panels/config/areas/ha-config-area-page.ts index 64b05b126b..e329854192 100644 --- a/src/panels/config/areas/ha-config-area-page.ts +++ b/src/panels/config/areas/ha-config-area-page.ts @@ -215,7 +215,12 @@ class HaConfigAreaPage extends LitElement { ` + : nothing}${area.name}`} > { const processArea = (area: AreaRegistryEntry) => { let noDevicesInArea = 0; @@ -73,18 +86,40 @@ export class HaConfigAreasDashboard extends LitElement { }; }; - return Object.values(areas).map(processArea); + const floorAreaLookup = getFloorAreaLookup(Object.values(areas)); + const unassisgnedAreas = Object.values(areas).filter( + (area) => !area.floor_id || !floorAreaLookup[area.floor_id] + ); + return { + floors: floors.map((floor) => ({ + ...floor, + areas: (floorAreaLookup[floor.floor_id] || []).map(processArea), + })), + unassisgnedAreas: unassisgnedAreas.map(processArea), + }; } ); + protected hassSubscribe(): (UnsubscribeFunc | Promise)[] { + return [ + subscribeFloorRegistry(this.hass.connection, (floors) => { + this._floors = floors; + }), + ]; + } + protected render(): TemplateResult { - const areas = - !this.hass.areas || !this.hass.devices || !this.hass.entities + const areasAndFloors = + !this.hass.areas || + !this.hass.devices || + !this.hass.entities || + !this._floors ? undefined : this._processAreas( this.hass.areas, this.hass.devices, - this.hass.entities + this.hass.entities, + this._floors ); return html` @@ -103,12 +138,55 @@ export class HaConfigAreasDashboard extends LitElement { @click=${this._showHelp} >
- ${areas?.length - ? html`
- ${areas.map((area) => this._renderArea(area))} + ${areasAndFloors?.floors.map( + (floor) => + html`
+
+

+ ${floor.icon + ? html`` + : nothing} + ${floor.name} +

+ +
+
+ ${floor.areas.map((area) => this._renderArea(area))} +
+
` + )} + ${areasAndFloors?.unassisgnedAreas.length + ? html`
+
+

+ ${this.hass.localize( + "ui.panel.config.areas.picker.unassigned_areas" + )} +

+
+
+ ${areasAndFloors?.unassisgnedAreas.map((area) => + this._renderArea(area) + )} +
` : nothing}
+ + + + createFloorRegistryEntry(this.hass!, values), + updateEntry: async (values) => + updateFloorRegistryEntry(this.hass!, entry!.floor_id, values), + }); + } + static get styles(): CSSResultGroup { return css` .container { padding: 8px 16px 16px; margin: 0 auto 64px auto; } + .header { + display: flex; + align-items: center; + justify-content: space-between; + color: var(--secondary-text-color); + padding-inline-start: 8px; + } + .header h2 { + font-size: 14px; + font-weight: 500; + margin-top: 28px; + } + .header ha-icon { + margin-inline-end: 8px; + } .areas { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); @@ -249,6 +361,10 @@ export class HaConfigAreasDashboard extends LitElement { min-height: 16px; color: var(--secondary-text-color); } + .floor { + --primary-color: var(--secondary-text-color); + margin-inline-end: 8px; + } `; } } diff --git a/src/panels/config/areas/show-dialog-floor-registry-detail.ts b/src/panels/config/areas/show-dialog-floor-registry-detail.ts new file mode 100644 index 0000000000..507d103469 --- /dev/null +++ b/src/panels/config/areas/show-dialog-floor-registry-detail.ts @@ -0,0 +1,27 @@ +import { fireEvent } from "../../../common/dom/fire_event"; +import { + FloorRegistryEntry, + FloorRegistryEntryMutableParams, +} from "../../../data/floor_registry"; + +export interface FloorRegistryDetailDialogParams { + entry?: FloorRegistryEntry; + createEntry?: (values: FloorRegistryEntryMutableParams) => Promise; + updateEntry?: ( + updates: Partial + ) => Promise; +} + +export const loadFloorRegistryDetailDialog = () => + import("./dialog-floor-registry-detail"); + +export const showFloorRegistryDetailDialog = ( + element: HTMLElement, + systemLogDetailParams: FloorRegistryDetailDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-floor-registry-detail", + dialogImport: loadFloorRegistryDetailDialog, + dialogParams: systemLogDetailParams, + }); +}; diff --git a/src/translations/en.json b/src/translations/en.json index 5b11bc6104..9f27f84e3d 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -484,9 +484,11 @@ }, "target-picker": { "expand": "Expand", + "expand_floor_id": "Split this floor into separate areas.", "expand_area_id": "Split this area into separate devices and entities.", "expand_device_id": "Split this device into separate entities.", "remove": "Remove", + "remove_floor_id": "Remove floor", "remove_area_id": "Remove area", "remove_device_id": "Remove device", "remove_entity_id": "Remove entity", @@ -550,6 +552,7 @@ "add_new": "Add new area…", "no_areas": "You don't have any areas", "no_match": "No matching areas found", + "unassigned_areas": "Unassigned areas", "add_dialog": { "title": "Add new area", "text": "Enter the name of the new area.", @@ -558,6 +561,22 @@ "failed_create_area": "Failed to create area." } }, + "floor-picker": { + "clear": "Clear", + "show_floors": "Show floors", + "floor": "Floor", + "add_new_sugestion": "Add new floor ''{name}''", + "add_new": "Add new floor…", + "no_floors": "You don't have any floors", + "no_match": "No matching floors found", + "add_dialog": { + "title": "Add new floor", + "text": "Enter the name of the new floor.", + "name": "Name", + "add": "Add", + "failed_create_floor": "Failed to create floor." + } + }, "area-filter": { "title": "Areas", "no_areas": "No areas", @@ -1760,6 +1779,23 @@ "ignored_in_version": "This issue was ignored in version {version}." } }, + "floors": { + "editor": { + "create_floor": "Create floor", + "update_floor": "Update floor", + "delete": "Delete", + "name": "Name", + "icon": "Icon", + "level": "Level", + "name_required": "Name is required", + "floor_id": "Floor ID", + "unknown_error": "Unknown error", + "aliases_section": "Aliases", + "no_aliases": "No configured aliases", + "configured_aliases": "{count} configured {count, plural,\n one {alias}\n other {aliases}\n}", + "aliases_description": "Aliases are alternative names used in voice assistants to refer to this floor." + } + }, "areas": { "caption": "Areas", "description": "Group devices and entities into areas", @@ -1779,7 +1815,9 @@ "introduction2": "To place devices in an area, use the link below to navigate to the integrations page and then click on a configured integration to get to the device cards.", "integrations_page": "Integrations page", "no_areas": "Looks like you have no areas yet!", - "create_area": "Create Area" + "unassigned_areas": "Unassigned areas", + "create_area": "Create Area", + "create_floor": "Create floor" }, "editor": { "create_area": "Create area", @@ -1787,6 +1825,7 @@ "delete": "Delete", "name": "Name", "icon": "Icon", + "floor": "Floor", "name_required": "Name is required", "area_id": "Area ID", "unknown_error": "Unknown error", diff --git a/yarn.lock b/yarn.lock index 66b271a280..6f67d5dadd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9738,7 +9738,7 @@ __metadata: gulp-rename: "npm:2.0.0" gulp-zopfli-green: "npm:6.0.1" hls.js: "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch" - home-assistant-js-websocket: "npm:9.1.0" + home-assistant-js-websocket: "npm:9.2.1" html-minifier-terser: "npm:7.2.0" husky: "npm:9.0.11" idb-keyval: "npm:6.2.1" @@ -9814,10 +9814,10 @@ __metadata: languageName: unknown linkType: soft -"home-assistant-js-websocket@npm:9.1.0": - version: 9.1.0 - resolution: "home-assistant-js-websocket@npm:9.1.0" - checksum: 10/9c1f2e20158d0eb7862e9f853807867af219f1a46d4bf2b5feb9db432dbe3a4032e466bea64d8650bb83daea1ef853b1ea7b985b29f5100d5c2375a3f40c32c6 +"home-assistant-js-websocket@npm:9.2.1": + version: 9.2.1 + resolution: "home-assistant-js-websocket@npm:9.2.1" + checksum: 10/0508aacb4285c805953e620968ef7ca7fc9c3cdac18fa723dd9af128dff74ef2ec65fad4079353b80363cd1daec6d2798b46d2d40a7e4ff5c0807ac71080bf58 languageName: node linkType: hard