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:
parent
45a5c1c235
commit
5289cd3af1
|
@ -101,6 +101,7 @@ const DEVICES = [
|
||||||
const AREAS: AreaRegistryEntry[] = [
|
const AREAS: AreaRegistryEntry[] = [
|
||||||
{
|
{
|
||||||
area_id: "backyard",
|
area_id: "backyard",
|
||||||
|
floor_id: null,
|
||||||
name: "Backyard",
|
name: "Backyard",
|
||||||
icon: null,
|
icon: null,
|
||||||
picture: null,
|
picture: null,
|
||||||
|
@ -108,6 +109,7 @@ const AREAS: AreaRegistryEntry[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
area_id: "bedroom",
|
area_id: "bedroom",
|
||||||
|
floor_id: null,
|
||||||
name: "Bedroom",
|
name: "Bedroom",
|
||||||
icon: "mdi:bed",
|
icon: "mdi:bed",
|
||||||
picture: null,
|
picture: null,
|
||||||
|
@ -115,6 +117,7 @@ const AREAS: AreaRegistryEntry[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
area_id: "livingroom",
|
area_id: "livingroom",
|
||||||
|
floor_id: null,
|
||||||
name: "Livingroom",
|
name: "Livingroom",
|
||||||
icon: "mdi:sofa",
|
icon: "mdi:sofa",
|
||||||
picture: null,
|
picture: null,
|
||||||
|
|
|
@ -97,6 +97,7 @@ const DEVICES = [
|
||||||
const AREAS: AreaRegistryEntry[] = [
|
const AREAS: AreaRegistryEntry[] = [
|
||||||
{
|
{
|
||||||
area_id: "backyard",
|
area_id: "backyard",
|
||||||
|
floor_id: null,
|
||||||
name: "Backyard",
|
name: "Backyard",
|
||||||
icon: null,
|
icon: null,
|
||||||
picture: null,
|
picture: null,
|
||||||
|
@ -104,6 +105,7 @@ const AREAS: AreaRegistryEntry[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
area_id: "bedroom",
|
area_id: "bedroom",
|
||||||
|
floor_id: null,
|
||||||
name: "Bedroom",
|
name: "Bedroom",
|
||||||
icon: "mdi:bed",
|
icon: "mdi:bed",
|
||||||
picture: null,
|
picture: null,
|
||||||
|
@ -111,6 +113,7 @@ const AREAS: AreaRegistryEntry[] = [
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
area_id: "livingroom",
|
area_id: "livingroom",
|
||||||
|
floor_id: null,
|
||||||
name: "Livingroom",
|
name: "Livingroom",
|
||||||
icon: "mdi:sofa",
|
icon: "mdi:sofa",
|
||||||
picture: null,
|
picture: null,
|
||||||
|
|
|
@ -111,7 +111,7 @@
|
||||||
"fuse.js": "7.0.0",
|
"fuse.js": "7.0.0",
|
||||||
"google-timezones-json": "1.2.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",
|
"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",
|
"idb-keyval": "6.2.1",
|
||||||
"intl-messageformat": "10.5.11",
|
"intl-messageformat": "10.5.11",
|
||||||
"js-yaml": "4.1.0",
|
"js-yaml": "4.1.0",
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -137,6 +137,7 @@ export class HaAreaPicker extends LitElement {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
area_id: "no_areas",
|
area_id: "no_areas",
|
||||||
|
floor_id: null,
|
||||||
name: this.hass.localize("ui.components.area-picker.no_areas"),
|
name: this.hass.localize("ui.components.area-picker.no_areas"),
|
||||||
picture: null,
|
picture: null,
|
||||||
icon: null,
|
icon: null,
|
||||||
|
@ -282,6 +283,7 @@ export class HaAreaPicker extends LitElement {
|
||||||
outputAreas = [
|
outputAreas = [
|
||||||
{
|
{
|
||||||
area_id: "no_areas",
|
area_id: "no_areas",
|
||||||
|
floor_id: null,
|
||||||
name: this.hass.localize("ui.components.area-picker.no_match"),
|
name: this.hass.localize("ui.components.area-picker.no_match"),
|
||||||
picture: null,
|
picture: null,
|
||||||
icon: null,
|
icon: null,
|
||||||
|
@ -296,6 +298,7 @@ export class HaAreaPicker extends LitElement {
|
||||||
...outputAreas,
|
...outputAreas,
|
||||||
{
|
{
|
||||||
area_id: "add_new",
|
area_id: "add_new",
|
||||||
|
floor_id: null,
|
||||||
name: this.hass.localize("ui.components.area-picker.add_new"),
|
name: this.hass.localize("ui.components.area-picker.add_new"),
|
||||||
picture: null,
|
picture: null,
|
||||||
icon: "mdi:plus",
|
icon: "mdi:plus",
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -30,6 +30,7 @@ import {
|
||||||
entityMeetsTargetSelector,
|
entityMeetsTargetSelector,
|
||||||
expandAreaTarget,
|
expandAreaTarget,
|
||||||
expandDeviceTarget,
|
expandDeviceTarget,
|
||||||
|
expandFloorTarget,
|
||||||
Selector,
|
Selector,
|
||||||
} from "../data/selector";
|
} from "../data/selector";
|
||||||
import { HomeAssistant, ValueChangedEvent } from "../types";
|
import { HomeAssistant, ValueChangedEvent } from "../types";
|
||||||
|
@ -58,20 +59,12 @@ const showOptionalToggle = (field) =>
|
||||||
!("boolean" in field.selector && field.default);
|
!("boolean" in field.selector && field.default);
|
||||||
|
|
||||||
interface ExtHassService extends Omit<HassService, "fields"> {
|
interface ExtHassService extends Omit<HassService, "fields"> {
|
||||||
fields: {
|
fields: Array<
|
||||||
|
Omit<HassService["fields"][string], "selector"> & {
|
||||||
key: string;
|
key: string;
|
||||||
name?: string;
|
|
||||||
description: string;
|
|
||||||
required?: boolean;
|
|
||||||
advanced?: boolean;
|
|
||||||
default?: any;
|
|
||||||
example?: any;
|
|
||||||
filter?: {
|
|
||||||
supported_features?: number[];
|
|
||||||
attribute?: Record<string, any[]>;
|
|
||||||
};
|
|
||||||
selector?: Selector;
|
selector?: Selector;
|
||||||
}[];
|
}
|
||||||
|
>;
|
||||||
hasSelector: string[];
|
hasSelector: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -275,10 +268,24 @@ export class HaServiceControl extends LitElement {
|
||||||
ensureArray(
|
ensureArray(
|
||||||
value?.target?.device_id || value?.data?.device_id
|
value?.target?.device_id || value?.data?.device_id
|
||||||
)?.slice() || [];
|
)?.slice() || [];
|
||||||
const targetAreas = ensureArray(
|
const targetAreas =
|
||||||
value?.target?.area_id || value?.data?.area_id
|
ensureArray(value?.target?.area_id || value?.data?.area_id)?.slice() ||
|
||||||
|
[];
|
||||||
|
const targetFloors = ensureArray(
|
||||||
|
value?.target?.floor_id || value?.data?.floor_id
|
||||||
)?.slice();
|
)?.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) => {
|
targetAreas.forEach((areaId) => {
|
||||||
const expanded = expandAreaTarget(
|
const expanded = expandAreaTarget(
|
||||||
this.hass,
|
this.hass,
|
||||||
|
|
|
@ -6,12 +6,17 @@ import "@material/mwc-menu/mwc-menu-surface";
|
||||||
import {
|
import {
|
||||||
mdiClose,
|
mdiClose,
|
||||||
mdiDevices,
|
mdiDevices,
|
||||||
|
mdiFloorPlan,
|
||||||
mdiPlus,
|
mdiPlus,
|
||||||
mdiSofa,
|
mdiSofa,
|
||||||
mdiUnfoldMoreVertical,
|
mdiUnfoldMoreVertical,
|
||||||
} from "@mdi/js";
|
} from "@mdi/js";
|
||||||
import { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
|
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 { css, CSSResultGroup, html, LitElement, nothing, unsafeCSS } from "lit";
|
||||||
import { customElement, property, query, state } from "lit/decorators";
|
import { customElement, property, query, state } from "lit/decorators";
|
||||||
import { classMap } from "lit/directives/class-map";
|
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 type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
|
||||||
import "./entity/ha-entity-picker";
|
import "./entity/ha-entity-picker";
|
||||||
import type { HaEntityPickerEntityFilterFunc } from "./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-icon-button";
|
||||||
import "./ha-input-helper-text";
|
import "./ha-input-helper-text";
|
||||||
import "./ha-svg-icon";
|
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")
|
@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 hass!: HomeAssistant;
|
||||||
|
|
||||||
@property({ attribute: false }) public value?: HassServiceTarget;
|
@property({ attribute: false }) public value?: HassServiceTarget;
|
||||||
|
@ -78,8 +89,18 @@ export class HaTargetPicker extends LitElement {
|
||||||
|
|
||||||
@query(".add-container", true) private _addContainer?: HTMLDivElement;
|
@query(".add-container", true) private _addContainer?: HTMLDivElement;
|
||||||
|
|
||||||
|
@state() private _floors?: FloorRegistryEntry[];
|
||||||
|
|
||||||
private _opened = false;
|
private _opened = false;
|
||||||
|
|
||||||
|
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
|
||||||
|
return [
|
||||||
|
subscribeFloorRegistry(this.hass.connection, (floors) => {
|
||||||
|
this._floors = floors;
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
protected render() {
|
protected render() {
|
||||||
if (this.addOnTop) {
|
if (this.addOnTop) {
|
||||||
return html` ${this._renderChips()} ${this._renderItems()} `;
|
return html` ${this._renderChips()} ${this._renderItems()} `;
|
||||||
|
@ -90,6 +111,21 @@ export class HaTargetPicker extends LitElement {
|
||||||
private _renderItems() {
|
private _renderItems() {
|
||||||
return html`
|
return html`
|
||||||
<div class="mdc-chip-set items">
|
<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
|
${this.value?.area_id
|
||||||
? ensureArray(this.value.area_id).map((area_id) => {
|
? ensureArray(this.value.area_id).map((area_id) => {
|
||||||
const area = this.hass.areas![area_id];
|
const area = this.hass.areas![area_id];
|
||||||
|
@ -207,7 +243,7 @@ export class HaTargetPicker extends LitElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
private _renderChip(
|
private _renderChip(
|
||||||
type: "area_id" | "device_id" | "entity_id",
|
type: "floor_id" | "area_id" | "device_id" | "entity_id",
|
||||||
id: string,
|
id: string,
|
||||||
name: string,
|
name: string,
|
||||||
entityState?: HassEntity,
|
entityState?: HassEntity,
|
||||||
|
@ -296,7 +332,7 @@ export class HaTargetPicker extends LitElement {
|
||||||
@input=${stopPropagation}
|
@input=${stopPropagation}
|
||||||
>${this._addMode === "area_id"
|
>${this._addMode === "area_id"
|
||||||
? html`
|
? html`
|
||||||
<ha-area-picker
|
<ha-area-floor-picker
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
id="input"
|
id="input"
|
||||||
.type=${"area_id"}
|
.type=${"area_id"}
|
||||||
|
@ -309,9 +345,10 @@ export class HaTargetPicker extends LitElement {
|
||||||
.includeDeviceClasses=${this.includeDeviceClasses}
|
.includeDeviceClasses=${this.includeDeviceClasses}
|
||||||
.includeDomains=${this.includeDomains}
|
.includeDomains=${this.includeDomains}
|
||||||
.excludeAreas=${ensureArray(this.value?.area_id)}
|
.excludeAreas=${ensureArray(this.value?.area_id)}
|
||||||
|
.excludeFloors=${ensureArray(this.value?.floor_id)}
|
||||||
@value-changed=${this._targetPicked}
|
@value-changed=${this._targetPicked}
|
||||||
@click=${this._preventDefault}
|
@click=${this._preventDefault}
|
||||||
></ha-area-picker>
|
></ha-area-floor-picker>
|
||||||
`
|
`
|
||||||
: this._addMode === "device_id"
|
: this._addMode === "device_id"
|
||||||
? html`
|
? html`
|
||||||
|
@ -356,18 +393,24 @@ export class HaTargetPicker extends LitElement {
|
||||||
if (!ev.detail.value) {
|
if (!ev.detail.value) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const value = ev.detail.value;
|
let value = ev.detail.value;
|
||||||
const target = ev.currentTarget;
|
const target = ev.currentTarget;
|
||||||
|
let type = target.type;
|
||||||
|
|
||||||
if (target.type === "entity_id" && !isValidEntityId(value)) {
|
if (type === "entity_id" && !isValidEntityId(value)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === "area_id") {
|
||||||
|
value = ev.detail.value.id;
|
||||||
|
type = `${ev.detail.value.type}_id`;
|
||||||
|
}
|
||||||
|
|
||||||
target.value = "";
|
target.value = "";
|
||||||
if (
|
if (
|
||||||
this.value &&
|
this.value &&
|
||||||
this.value[target.type] &&
|
this.value[type] &&
|
||||||
ensureArray(this.value[target.type]).includes(value)
|
ensureArray(this.value[type]).includes(value)
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -375,19 +418,31 @@ export class HaTargetPicker extends LitElement {
|
||||||
value: this.value
|
value: this.value
|
||||||
? {
|
? {
|
||||||
...this.value,
|
...this.value,
|
||||||
[target.type]: this.value[target.type]
|
[type]: this.value[type]
|
||||||
? [...ensureArray(this.value[target.type]), value]
|
? [...ensureArray(this.value[type]), value]
|
||||||
: value,
|
: value,
|
||||||
}
|
}
|
||||||
: { [target.type]: value },
|
: { [type]: value },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private _handleExpand(ev) {
|
private _handleExpand(ev) {
|
||||||
const target = ev.currentTarget as any;
|
const target = ev.currentTarget as any;
|
||||||
|
const newAreas: string[] = [];
|
||||||
const newDevices: string[] = [];
|
const newDevices: string[] = [];
|
||||||
const newEntities: 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) => {
|
Object.values(this.hass.devices).forEach((device) => {
|
||||||
if (
|
if (
|
||||||
device.area_id === target.id &&
|
device.area_id === target.id &&
|
||||||
|
@ -426,6 +481,9 @@ export class HaTargetPicker extends LitElement {
|
||||||
if (newDevices.length) {
|
if (newDevices.length) {
|
||||||
value = this._addItems(value, "device_id", newDevices);
|
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);
|
value = this._removeItem(value, target.type, target.id);
|
||||||
fireEvent(this, "value-changed", { value });
|
fireEvent(this, "value-changed", { value });
|
||||||
}
|
}
|
||||||
|
@ -495,6 +553,26 @@ export class HaTargetPicker extends LitElement {
|
||||||
ev.preventDefault();
|
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 {
|
private _deviceMeetsFilter(device: DeviceRegistryEntry): boolean {
|
||||||
const devEntities = Object.values(this.hass.entities).filter(
|
const devEntities = Object.values(this.hass.entities).filter(
|
||||||
(entity) => entity.device_id === device.id
|
(entity) => entity.device_id === device.id
|
||||||
|
@ -651,12 +729,15 @@ export class HaTargetPicker extends LitElement {
|
||||||
margin-inline-end: 0;
|
margin-inline-end: 0;
|
||||||
margin-inline-start: initial;
|
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;
|
border: 2px solid #fed6a4;
|
||||||
background: var(--card-background-color);
|
background: var(--card-background-color);
|
||||||
}
|
}
|
||||||
.mdc-chip.area_id:not(.add) .mdc-chip__icon--leading,
|
.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;
|
background: #fed6a4;
|
||||||
}
|
}
|
||||||
.mdc-chip.device_id:not(.add) {
|
.mdc-chip.device_id:not(.add) {
|
||||||
|
@ -690,7 +771,7 @@ export class HaTargetPicker extends LitElement {
|
||||||
}
|
}
|
||||||
ha-entity-picker,
|
ha-entity-picker,
|
||||||
ha-device-picker,
|
ha-device-picker,
|
||||||
ha-area-picker {
|
ha-area-floor-picker {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@ export { subscribeAreaRegistry } from "./ws-area_registry";
|
||||||
|
|
||||||
export interface AreaRegistryEntry {
|
export interface AreaRegistryEntry {
|
||||||
area_id: string;
|
area_id: string;
|
||||||
|
floor_id: string | null;
|
||||||
name: string;
|
name: string;
|
||||||
picture: string | null;
|
picture: string | null;
|
||||||
icon: string | null;
|
icon: string | null;
|
||||||
|
@ -23,6 +24,7 @@ export interface AreaDeviceLookup {
|
||||||
|
|
||||||
export interface AreaRegistryEntryMutableParams {
|
export interface AreaRegistryEntryMutableParams {
|
||||||
name: string;
|
name: string;
|
||||||
|
floor_id?: string | null;
|
||||||
picture?: string | null;
|
picture?: string | null;
|
||||||
icon?: string | null;
|
icon?: string | null;
|
||||||
aliases?: string[];
|
aliases?: string[];
|
||||||
|
|
|
@ -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;
|
||||||
|
};
|
|
@ -424,6 +424,32 @@ export interface UiColorSelector {
|
||||||
ui_color: {} | null;
|
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 = (
|
export const expandAreaTarget = (
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
areaId: string,
|
areaId: string,
|
||||||
|
|
|
@ -10,6 +10,7 @@ import "../../../components/ha-picture-upload";
|
||||||
import type { HaPictureUpload } from "../../../components/ha-picture-upload";
|
import type { HaPictureUpload } from "../../../components/ha-picture-upload";
|
||||||
import "../../../components/ha-settings-row";
|
import "../../../components/ha-settings-row";
|
||||||
import "../../../components/ha-icon-picker";
|
import "../../../components/ha-icon-picker";
|
||||||
|
import "../../../components/ha-floor-picker";
|
||||||
import "../../../components/ha-textfield";
|
import "../../../components/ha-textfield";
|
||||||
import { AreaRegistryEntryMutableParams } from "../../../data/area_registry";
|
import { AreaRegistryEntryMutableParams } from "../../../data/area_registry";
|
||||||
import { CropOptions } from "../../../dialogs/image-cropper-dialog/show-image-cropper-dialog";
|
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 _icon!: string | null;
|
||||||
|
|
||||||
|
@state() private _floor!: string | null;
|
||||||
|
|
||||||
@state() private _error?: string;
|
@state() private _error?: string;
|
||||||
|
|
||||||
@state() private _params?: AreaRegistryDetailDialogParams;
|
@state() private _params?: AreaRegistryDetailDialogParams;
|
||||||
|
@ -50,6 +53,7 @@ class DialogAreaDetail extends LitElement {
|
||||||
this._aliases = this._params.entry ? this._params.entry.aliases : [];
|
this._aliases = this._params.entry ? this._params.entry.aliases : [];
|
||||||
this._picture = this._params.entry?.picture || null;
|
this._picture = this._params.entry?.picture || null;
|
||||||
this._icon = this._params.entry?.icon || null;
|
this._icon = this._params.entry?.icon || null;
|
||||||
|
this._floor = this._params.entry?.floor_id || null;
|
||||||
await this.updateComplete;
|
await this.updateComplete;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -112,6 +116,13 @@ class DialogAreaDetail extends LitElement {
|
||||||
.label=${this.hass.localize("ui.panel.config.areas.editor.icon")}
|
.label=${this.hass.localize("ui.panel.config.areas.editor.icon")}
|
||||||
></ha-icon-picker>
|
></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
|
<ha-picture-upload
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.value=${this._picture}
|
.value=${this._picture}
|
||||||
|
@ -163,6 +174,11 @@ class DialogAreaDetail extends LitElement {
|
||||||
this._name = ev.target.value;
|
this._name = ev.target.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _floorChanged(ev) {
|
||||||
|
this._error = undefined;
|
||||||
|
this._floor = ev.detail.value;
|
||||||
|
}
|
||||||
|
|
||||||
private _iconChanged(ev) {
|
private _iconChanged(ev) {
|
||||||
this._error = undefined;
|
this._error = undefined;
|
||||||
this._icon = ev.detail.value;
|
this._icon = ev.detail.value;
|
||||||
|
@ -181,6 +197,7 @@ class DialogAreaDetail extends LitElement {
|
||||||
name: this._name.trim(),
|
name: this._name.trim(),
|
||||||
picture: this._picture || (create ? undefined : null),
|
picture: this._picture || (create ? undefined : null),
|
||||||
icon: this._icon || (create ? undefined : null),
|
icon: this._icon || (create ? undefined : null),
|
||||||
|
floor_id: this._floor || (create ? undefined : null),
|
||||||
aliases: this._aliases,
|
aliases: this._aliases,
|
||||||
};
|
};
|
||||||
if (create) {
|
if (create) {
|
||||||
|
@ -208,6 +225,7 @@ class DialogAreaDetail extends LitElement {
|
||||||
css`
|
css`
|
||||||
ha-textfield,
|
ha-textfield,
|
||||||
ha-icon-picker,
|
ha-icon-picker,
|
||||||
|
ha-floor-picker,
|
||||||
ha-picture-upload {
|
ha-picture-upload {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
|
|
|
@ -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);
|
|
@ -215,7 +215,12 @@ class HaConfigAreaPage extends LitElement {
|
||||||
<hass-subpage
|
<hass-subpage
|
||||||
.hass=${this.hass}
|
.hass=${this.hass}
|
||||||
.narrow=${this.narrow}
|
.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-button-menu slot="toolbar-icon">
|
||||||
<ha-icon-button
|
<ha-icon-button
|
||||||
|
|
|
@ -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 {
|
import {
|
||||||
CSSResultGroup,
|
CSSResultGroup,
|
||||||
LitElement,
|
LitElement,
|
||||||
|
@ -7,7 +8,7 @@ import {
|
||||||
html,
|
html,
|
||||||
nothing,
|
nothing,
|
||||||
} from "lit";
|
} from "lit";
|
||||||
import { customElement, property } from "lit/decorators";
|
import { customElement, property, state } from "lit/decorators";
|
||||||
import { styleMap } from "lit/directives/style-map";
|
import { styleMap } from "lit/directives/style-map";
|
||||||
import memoizeOne from "memoize-one";
|
import memoizeOne from "memoize-one";
|
||||||
import { formatListWithAnds } from "../../../common/string/format-list";
|
import { formatListWithAnds } from "../../../common/string/format-list";
|
||||||
|
@ -18,6 +19,13 @@ import {
|
||||||
AreaRegistryEntry,
|
AreaRegistryEntry,
|
||||||
createAreaRegistryEntry,
|
createAreaRegistryEntry,
|
||||||
} from "../../../data/area_registry";
|
} from "../../../data/area_registry";
|
||||||
|
import {
|
||||||
|
FloorRegistryEntry,
|
||||||
|
createFloorRegistryEntry,
|
||||||
|
getFloorAreaLookup,
|
||||||
|
subscribeFloorRegistry,
|
||||||
|
updateFloorRegistryEntry,
|
||||||
|
} from "../../../data/floor_registry";
|
||||||
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
|
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
|
||||||
import "../../../layouts/hass-tabs-subpage";
|
import "../../../layouts/hass-tabs-subpage";
|
||||||
import { HomeAssistant, Route } from "../../../types";
|
import { HomeAssistant, Route } from "../../../types";
|
||||||
|
@ -27,9 +35,11 @@ import {
|
||||||
loadAreaRegistryDetailDialog,
|
loadAreaRegistryDetailDialog,
|
||||||
showAreaRegistryDetailDialog,
|
showAreaRegistryDetailDialog,
|
||||||
} from "./show-dialog-area-registry-detail";
|
} 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")
|
@customElement("ha-config-areas-dashboard")
|
||||||
export class HaConfigAreasDashboard extends LitElement {
|
export class HaConfigAreasDashboard extends SubscribeMixin(LitElement) {
|
||||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||||
|
|
||||||
@property({ type: Boolean }) public isWide = false;
|
@property({ type: Boolean }) public isWide = false;
|
||||||
|
@ -38,11 +48,14 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||||
|
|
||||||
@property({ attribute: false }) public route!: Route;
|
@property({ attribute: false }) public route!: Route;
|
||||||
|
|
||||||
|
@state() private _floors?: FloorRegistryEntry[];
|
||||||
|
|
||||||
private _processAreas = memoizeOne(
|
private _processAreas = memoizeOne(
|
||||||
(
|
(
|
||||||
areas: HomeAssistant["areas"],
|
areas: HomeAssistant["areas"],
|
||||||
devices: HomeAssistant["devices"],
|
devices: HomeAssistant["devices"],
|
||||||
entities: HomeAssistant["entities"]
|
entities: HomeAssistant["entities"],
|
||||||
|
floors: FloorRegistryEntry[]
|
||||||
) => {
|
) => {
|
||||||
const processArea = (area: AreaRegistryEntry) => {
|
const processArea = (area: AreaRegistryEntry) => {
|
||||||
let noDevicesInArea = 0;
|
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 {
|
protected render(): TemplateResult {
|
||||||
const areas =
|
const areasAndFloors =
|
||||||
!this.hass.areas || !this.hass.devices || !this.hass.entities
|
!this.hass.areas ||
|
||||||
|
!this.hass.devices ||
|
||||||
|
!this.hass.entities ||
|
||||||
|
!this._floors
|
||||||
? undefined
|
? undefined
|
||||||
: this._processAreas(
|
: this._processAreas(
|
||||||
this.hass.areas,
|
this.hass.areas,
|
||||||
this.hass.devices,
|
this.hass.devices,
|
||||||
this.hass.entities
|
this.hass.entities,
|
||||||
|
this._floors
|
||||||
);
|
);
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
|
@ -103,12 +138,55 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||||
@click=${this._showHelp}
|
@click=${this._showHelp}
|
||||||
></ha-icon-button>
|
></ha-icon-button>
|
||||||
<div class="container">
|
<div class="container">
|
||||||
${areas?.length
|
${areasAndFloors?.floors.map(
|
||||||
? html`<div class="areas">
|
(floor) =>
|
||||||
${areas.map((area) => this._renderArea(area))}
|
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>`
|
</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
</div>
|
</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
|
<ha-fab
|
||||||
slot="fab"
|
slot="fab"
|
||||||
.label=${this.hass.localize(
|
.label=${this.hass.localize(
|
||||||
|
@ -170,6 +248,15 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||||
loadAreaRegistryDetailDialog();
|
loadAreaRegistryDetailDialog();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _createFloor() {
|
||||||
|
this._openFloorDialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
private _editFloor(ev) {
|
||||||
|
const floor = ev.currentTarget.floor;
|
||||||
|
this._openFloorDialog(floor);
|
||||||
|
}
|
||||||
|
|
||||||
private _createArea() {
|
private _createArea() {
|
||||||
this._openAreaDialog();
|
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 {
|
static get styles(): CSSResultGroup {
|
||||||
return css`
|
return css`
|
||||||
.container {
|
.container {
|
||||||
padding: 8px 16px 16px;
|
padding: 8px 16px 16px;
|
||||||
margin: 0 auto 64px auto;
|
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 {
|
.areas {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
@ -249,6 +361,10 @@ export class HaConfigAreasDashboard extends LitElement {
|
||||||
min-height: 16px;
|
min-height: 16px;
|
||||||
color: var(--secondary-text-color);
|
color: var(--secondary-text-color);
|
||||||
}
|
}
|
||||||
|
.floor {
|
||||||
|
--primary-color: var(--secondary-text-color);
|
||||||
|
margin-inline-end: 8px;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
});
|
||||||
|
};
|
|
@ -484,9 +484,11 @@
|
||||||
},
|
},
|
||||||
"target-picker": {
|
"target-picker": {
|
||||||
"expand": "Expand",
|
"expand": "Expand",
|
||||||
|
"expand_floor_id": "Split this floor into separate areas.",
|
||||||
"expand_area_id": "Split this area into separate devices and entities.",
|
"expand_area_id": "Split this area into separate devices and entities.",
|
||||||
"expand_device_id": "Split this device into separate entities.",
|
"expand_device_id": "Split this device into separate entities.",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
|
"remove_floor_id": "Remove floor",
|
||||||
"remove_area_id": "Remove area",
|
"remove_area_id": "Remove area",
|
||||||
"remove_device_id": "Remove device",
|
"remove_device_id": "Remove device",
|
||||||
"remove_entity_id": "Remove entity",
|
"remove_entity_id": "Remove entity",
|
||||||
|
@ -550,6 +552,7 @@
|
||||||
"add_new": "Add new area…",
|
"add_new": "Add new area…",
|
||||||
"no_areas": "You don't have any areas",
|
"no_areas": "You don't have any areas",
|
||||||
"no_match": "No matching areas found",
|
"no_match": "No matching areas found",
|
||||||
|
"unassigned_areas": "Unassigned areas",
|
||||||
"add_dialog": {
|
"add_dialog": {
|
||||||
"title": "Add new area",
|
"title": "Add new area",
|
||||||
"text": "Enter the name of the new area.",
|
"text": "Enter the name of the new area.",
|
||||||
|
@ -558,6 +561,22 @@
|
||||||
"failed_create_area": "Failed to create area."
|
"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": {
|
"area-filter": {
|
||||||
"title": "Areas",
|
"title": "Areas",
|
||||||
"no_areas": "No areas",
|
"no_areas": "No areas",
|
||||||
|
@ -1760,6 +1779,23 @@
|
||||||
"ignored_in_version": "This issue was ignored in version {version}."
|
"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": {
|
"areas": {
|
||||||
"caption": "Areas",
|
"caption": "Areas",
|
||||||
"description": "Group devices and entities into 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.",
|
"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",
|
"integrations_page": "Integrations page",
|
||||||
"no_areas": "Looks like you have no areas yet!",
|
"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": {
|
"editor": {
|
||||||
"create_area": "Create area",
|
"create_area": "Create area",
|
||||||
|
@ -1787,6 +1825,7 @@
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"icon": "Icon",
|
"icon": "Icon",
|
||||||
|
"floor": "Floor",
|
||||||
"name_required": "Name is required",
|
"name_required": "Name is required",
|
||||||
"area_id": "Area ID",
|
"area_id": "Area ID",
|
||||||
"unknown_error": "Unknown error",
|
"unknown_error": "Unknown error",
|
||||||
|
|
10
yarn.lock
10
yarn.lock
|
@ -9738,7 +9738,7 @@ __metadata:
|
||||||
gulp-rename: "npm:2.0.0"
|
gulp-rename: "npm:2.0.0"
|
||||||
gulp-zopfli-green: "npm:6.0.1"
|
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"
|
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"
|
html-minifier-terser: "npm:7.2.0"
|
||||||
husky: "npm:9.0.11"
|
husky: "npm:9.0.11"
|
||||||
idb-keyval: "npm:6.2.1"
|
idb-keyval: "npm:6.2.1"
|
||||||
|
@ -9814,10 +9814,10 @@ __metadata:
|
||||||
languageName: unknown
|
languageName: unknown
|
||||||
linkType: soft
|
linkType: soft
|
||||||
|
|
||||||
"home-assistant-js-websocket@npm:9.1.0":
|
"home-assistant-js-websocket@npm:9.2.1":
|
||||||
version: 9.1.0
|
version: 9.2.1
|
||||||
resolution: "home-assistant-js-websocket@npm:9.1.0"
|
resolution: "home-assistant-js-websocket@npm:9.2.1"
|
||||||
checksum: 10/9c1f2e20158d0eb7862e9f853807867af219f1a46d4bf2b5feb9db432dbe3a4032e466bea64d8650bb83daea1ef853b1ea7b985b29f5100d5c2375a3f40c32c6
|
checksum: 10/0508aacb4285c805953e620968ef7ca7fc9c3cdac18fa723dd9af128dff74ef2ec65fad4079353b80363cd1daec6d2798b46d2d40a7e4ff5c0807ac71080bf58
|
||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue