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[] = [
|
||||
{
|
||||
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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 [
|
||||
{
|
||||
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",
|
||||
|
|
|
@ -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,
|
||||
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,
|
||||
|
|
|
@ -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%;
|
||||
}
|
||||
|
|
|
@ -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[];
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
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,
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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=${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
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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": {
|
||||
"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",
|
||||
|
|
10
yarn.lock
10
yarn.lock
|
@ -9738,7 +9738,7 @@ __metadata:
|
|||
gulp-rename: "npm:2.0.0"
|
||||
gulp-zopfli-green: "npm:6.0.1"
|
||||
hls.js: "patch:hls.js@npm%3A1.5.7#~/.yarn/patches/hls.js-npm-1.5.7-f5bbd3d060.patch"
|
||||
home-assistant-js-websocket: "npm:9.1.0"
|
||||
home-assistant-js-websocket: "npm:9.2.1"
|
||||
html-minifier-terser: "npm:7.2.0"
|
||||
husky: "npm:9.0.11"
|
||||
idb-keyval: "npm:6.2.1"
|
||||
|
@ -9814,10 +9814,10 @@ __metadata:
|
|||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"home-assistant-js-websocket@npm:9.1.0":
|
||||
version: 9.1.0
|
||||
resolution: "home-assistant-js-websocket@npm:9.1.0"
|
||||
checksum: 10/9c1f2e20158d0eb7862e9f853807867af219f1a46d4bf2b5feb9db432dbe3a4032e466bea64d8650bb83daea1ef853b1ea7b985b29f5100d5c2375a3f40c32c6
|
||||
"home-assistant-js-websocket@npm:9.2.1":
|
||||
version: 9.2.1
|
||||
resolution: "home-assistant-js-websocket@npm:9.2.1"
|
||||
checksum: 10/0508aacb4285c805953e620968ef7ca7fc9c3cdac18fa723dd9af128dff74ef2ec65fad4079353b80363cd1daec6d2798b46d2d40a7e4ff5c0807ac71080bf58
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
|
Loading…
Reference in New Issue