Add floor support (#20187)

* Add floor support

* Update src/components/ha-area-floor-picker.ts

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>

* Use different type for floor area picker

* type

---------

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
This commit is contained in:
Bram Kragten 2024-03-26 18:00:09 +01:00 committed by GitHub
parent 45a5c1c235
commit 5289cd3af1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1724 additions and 53 deletions

View File

@ -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,

View File

@ -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,

View File

@ -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",

View File

@ -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<FloorAreaEntry> = (item) =>
item.type === "floor"
? html`<ha-list-item graphic="icon" class="floor">
${item.icon
? html`<ha-icon slot="graphic" .icon=${item.icon}></ha-icon>`
: nothing}
${item.name}
</ha-list-item>`
: html`<ha-list-item
graphic="icon"
style="--mdc-list-side-padding-left: 48px;"
>
${item.icon
? html`<ha-icon slot="graphic" .icon=${item.icon}></ha-icon>`
: nothing}
${item.name}
</ha-list-item>`;
@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<UnsubscribeFunc>)[] {
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`
<ha-combo-box
.hass=${this.hass}
.helper=${this.helper}
item-value-path="id"
item-id-path="id"
item-label-path="name"
.value=${this._value}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.area-picker.area")
: this.label}
.placeholder=${this.placeholder
? this.hass.areas[this.placeholder]?.name
: undefined}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._areaChanged}
>
</ha-combo-box>
`;
}
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<ScorableAreaFloorEntry>(
filterString,
target.items || []
);
this.comboBox.filteredItems = filteredItems;
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private async _areaChanged(ev: ValueChangedEvent<string>) {
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;
}
}

View File

@ -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",

View File

@ -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<FloorRegistryEntry> = (item) =>
html`<ha-list-item
graphic="icon"
class=${classMap({ "add-new": item.floor_id === "add_new" })}
>
${item.icon
? html`<ha-icon slot="graphic" .icon=${item.icon}></ha-icon>`
: nothing}
${item.name}
</ha-list-item>`;
@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<UnsubscribeFunc>)[] {
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`
<ha-combo-box
.hass=${this.hass}
.helper=${this.helper}
item-value-path="floor_id"
item-id-path="floor_id"
item-label-path="name"
.value=${this._value}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.floor-picker.floor")
: this.label}
.placeholder=${this.placeholder
? this._floors?.find((floor) => floor.floor_id === this.placeholder)
?.name
: undefined}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._floorChanged}
>
</ha-combo-box>
`;
}
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<ScorableFloorRegistryEntry>(
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<boolean>) {
this._opened = ev.detail.value;
}
private _floorChanged(ev: ValueChangedEvent<string>) {
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;
}
}

View File

@ -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<HassService, "fields"> {
fields: {
key: string;
name?: string;
description: string;
required?: boolean;
advanced?: boolean;
default?: any;
example?: any;
filter?: {
supported_features?: number[];
attribute?: Record<string, any[]>;
};
selector?: Selector;
}[];
fields: Array<
Omit<HassService["fields"][string], "selector"> & {
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,

View File

@ -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<UnsubscribeFunc>)[] {
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`
<div class="mdc-chip-set items">
${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`
<ha-area-picker
<ha-area-floor-picker
.hass=${this.hass}
id="input"
.type=${"area_id"}
@ -309,9 +345,10 @@ export class HaTargetPicker extends LitElement {
.includeDeviceClasses=${this.includeDeviceClasses}
.includeDomains=${this.includeDomains}
.excludeAreas=${ensureArray(this.value?.area_id)}
.excludeFloors=${ensureArray(this.value?.floor_id)}
@value-changed=${this._targetPicked}
@click=${this._preventDefault}
></ha-area-picker>
></ha-area-floor-picker>
`
: 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%;
}

View File

@ -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[];

133
src/data/floor_registry.ts Normal file
View File

@ -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<FloorRegistryEntry[]>
) =>
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<FloorRegistryEntry[]>(
"_floorRegistry",
fetchFloorRegistry,
subscribeFloorRegistryUpdates,
conn,
onChange
);
export const createFloorRegistryEntry = (
hass: HomeAssistant,
values: FloorRegistryEntryMutableParams
) =>
hass.callWS<FloorRegistryEntry>({
type: "config/floor_registry/create",
...values,
});
export const updateFloorRegistryEntry = (
hass: HomeAssistant,
floorId: string,
updates: Partial<FloorRegistryEntryMutableParams>
) =>
hass.callWS<AreaRegistryEntry>({
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;
};

View File

@ -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,

View File

@ -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")}
></ha-icon-picker>
<ha-floor-picker
.hass=${this.hass}
.value=${this._floor}
@value-changed=${this._floorChanged}
.label=${this.hass.localize("ui.panel.config.areas.editor.floor")}
></ha-floor-picker>
<ha-picture-upload
.hass=${this.hass}
.value=${this._picture}
@ -163,6 +174,11 @@ class DialogAreaDetail extends LitElement {
this._name = ev.target.value;
}
private _floorChanged(ev) {
this._error = undefined;
this._floor = ev.detail.value;
}
private _iconChanged(ev) {
this._error = undefined;
this._icon = ev.detail.value;
@ -181,6 +197,7 @@ class DialogAreaDetail extends LitElement {
name: this._name.trim(),
picture: this._picture || (create ? undefined : null),
icon: this._icon || (create ? undefined : null),
floor_id: this._floor || (create ? undefined : null),
aliases: this._aliases,
};
if (create) {
@ -208,6 +225,7 @@ class DialogAreaDetail extends LitElement {
css`
ha-textfield,
ha-icon-picker,
ha-floor-picker,
ha-picture-upload {
display: block;
margin-bottom: 16px;

View File

@ -0,0 +1,216 @@
import "@material/mwc-button";
import "@material/mwc-list/mwc-list";
import { css, CSSResultGroup, html, LitElement, nothing } from "lit";
import { property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-aliases-editor";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-picture-upload";
import "../../../components/ha-settings-row";
import "../../../components/ha-textfield";
import "../../../components/ha-icon-picker";
import { FloorRegistryEntryMutableParams } from "../../../data/floor_registry";
import { haStyleDialog } from "../../../resources/styles";
import { HomeAssistant } from "../../../types";
import { FloorRegistryDetailDialogParams } from "./show-dialog-floor-registry-detail";
class DialogFloorDetail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _name!: string;
@state() private _aliases!: string[];
@state() private _icon!: string | null;
@state() private _level!: number;
@state() private _error?: string;
@state() private _params?: FloorRegistryDetailDialogParams;
@state() private _submitting?: boolean;
public async showDialog(
params: FloorRegistryDetailDialogParams
): Promise<void> {
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`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${createCloseHeading(
this.hass,
entry
? this.hass.localize("ui.panel.config.floors.editor.update_floor")
: this.hass.localize("ui.panel.config.floors.editor.create_floor")
)}
>
<div>
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: ""}
<div class="form">
${entry
? html`
<ha-settings-row>
<span slot="heading">
${this.hass.localize(
"ui.panel.config.floors.editor.floor_id"
)}
</span>
<span slot="description">${entry.floor_id}</span>
</ha-settings-row>
`
: nothing}
<ha-textfield
.value=${this._name}
@input=${this._nameChanged}
.label=${this.hass.localize("ui.panel.config.floors.editor.name")}
.validationMessage=${this.hass.localize(
"ui.panel.config.floors.editor.name_required"
)}
required
dialogInitialFocus
></ha-textfield>
<ha-textfield
.value=${this._level}
@input=${this._levelChanged}
.label=${this.hass.localize(
"ui.panel.config.floors.editor.level"
)}
type="number"
></ha-textfield>
<ha-icon-picker
.hass=${this.hass}
.value=${this._icon}
@value-changed=${this._iconChanged}
.label=${this.hass.localize("ui.panel.config.areas.editor.icon")}
></ha-icon-picker>
<h3 class="header">
${this.hass.localize(
"ui.panel.config.floors.editor.aliases_section"
)}
</h3>
<p class="description">
${this.hass.localize(
"ui.panel.config.floors.editor.aliases_description"
)}
</p>
<ha-aliases-editor
.hass=${this.hass}
.aliases=${this._aliases}
@value-changed=${this._aliasesChanged}
></ha-aliases-editor>
</div>
</div>
<mwc-button slot="secondaryAction" @click=${this.closeDialog}>
${this.hass.localize("ui.common.cancel")}
</mwc-button>
<mwc-button
slot="primaryAction"
@click=${this._updateEntry}
.disabled=${nameInvalid || this._submitting}
>
${entry
? this.hass.localize("ui.common.save")
: this.hass.localize("ui.common.add")}
</mwc-button>
</ha-dialog>
`;
}
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);

View File

@ -215,7 +215,12 @@ class HaConfigAreaPage extends LitElement {
<hass-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.header=${area.name}
.header=${html`${area.icon
? html`<ha-icon
.icon=${area.icon}
style="margin-inline-end: 8px;"
></ha-icon>`
: nothing}${area.name}`}
>
<ha-button-menu slot="toolbar-icon">
<ha-icon-button

View File

@ -1,4 +1,5 @@
import { mdiHelpCircle, mdiPlus } from "@mdi/js";
import { mdiHelpCircle, mdiPencil, mdiPlus } from "@mdi/js";
import { UnsubscribeFunc } from "home-assistant-js-websocket";
import {
CSSResultGroup,
LitElement,
@ -7,7 +8,7 @@ import {
html,
nothing,
} from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { formatListWithAnds } from "../../../common/string/format-list";
@ -18,6 +19,13 @@ import {
AreaRegistryEntry,
createAreaRegistryEntry,
} from "../../../data/area_registry";
import {
FloorRegistryEntry,
createFloorRegistryEntry,
getFloorAreaLookup,
subscribeFloorRegistry,
updateFloorRegistryEntry,
} from "../../../data/floor_registry";
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-tabs-subpage";
import { HomeAssistant, Route } from "../../../types";
@ -27,9 +35,11 @@ import {
loadAreaRegistryDetailDialog,
showAreaRegistryDetailDialog,
} from "./show-dialog-area-registry-detail";
import { showFloorRegistryDetailDialog } from "./show-dialog-floor-registry-detail";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
@customElement("ha-config-areas-dashboard")
export class HaConfigAreasDashboard extends LitElement {
export class HaConfigAreasDashboard extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean }) public isWide = false;
@ -38,11 +48,14 @@ export class HaConfigAreasDashboard extends LitElement {
@property({ attribute: false }) public route!: Route;
@state() private _floors?: FloorRegistryEntry[];
private _processAreas = memoizeOne(
(
areas: HomeAssistant["areas"],
devices: HomeAssistant["devices"],
entities: HomeAssistant["entities"]
entities: HomeAssistant["entities"],
floors: FloorRegistryEntry[]
) => {
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<UnsubscribeFunc>)[] {
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}
></ha-icon-button>
<div class="container">
${areas?.length
? html`<div class="areas">
${areas.map((area) => this._renderArea(area))}
${areasAndFloors?.floors.map(
(floor) =>
html`<div class="floor">
<div class="header">
<h2>
${floor.icon
? html`<ha-icon .icon=${floor.icon}></ha-icon>`
: nothing}
${floor.name}
</h2>
<ha-icon-button
.path=${mdiPencil}
@click=${this._editFloor}
.floor=${floor}
></ha-icon-button>
</div>
<div class="areas">
${floor.areas.map((area) => this._renderArea(area))}
</div>
</div>`
)}
${areasAndFloors?.unassisgnedAreas.length
? html`<div class="unassigned">
<div class="header">
<h2>
${this.hass.localize(
"ui.panel.config.areas.picker.unassigned_areas"
)}
</h2>
</div>
<div class="areas">
${areasAndFloors?.unassisgnedAreas.map((area) =>
this._renderArea(area)
)}
</div>
</div>`
: nothing}
</div>
<ha-fab
slot="fab"
class="floor"
.label=${this.hass.localize(
"ui.panel.config.areas.picker.create_floor"
)}
extended
@click=${this._createFloor}
>
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>
</ha-fab>
<ha-fab
slot="fab"
.label=${this.hass.localize(
@ -170,6 +248,15 @@ export class HaConfigAreasDashboard extends LitElement {
loadAreaRegistryDetailDialog();
}
private _createFloor() {
this._openFloorDialog();
}
private _editFloor(ev) {
const floor = ev.currentTarget.floor;
this._openFloorDialog(floor);
}
private _createArea() {
this._openAreaDialog();
}
@ -199,12 +286,37 @@ export class HaConfigAreasDashboard extends LitElement {
});
}
private _openFloorDialog(entry?: FloorRegistryEntry) {
showFloorRegistryDetailDialog(this, {
entry,
createEntry: async (values) =>
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;
}
`;
}
}

View File

@ -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<unknown>;
updateEntry?: (
updates: Partial<FloorRegistryEntryMutableParams>
) => Promise<unknown>;
}
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,
});
};

View File

@ -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",

View File

@ -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