Add floor selector (#20295)

This commit is contained in:
Bram Kragten 2024-04-02 10:43:50 +02:00 committed by GitHub
parent 1ce3347c2e
commit 85f2016371
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 430 additions and 13 deletions

View File

@ -4,4 +4,11 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockAreaRegistry = (
hass: MockHomeAssistant,
data: AreaRegistryEntry[] = []
) => hass.mockWS("config/area_registry/list", () => data);
) => {
hass.mockWS("config/area_registry/list", () => data);
const areas = {};
data.forEach((area) => {
areas[area.area_id] = area;
});
hass.updateHass({ areas });
};

View File

@ -4,4 +4,11 @@ import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockDeviceRegistry = (
hass: MockHomeAssistant,
data: DeviceRegistryEntry[] = []
) => hass.mockWS("config/device_registry/list", () => data);
) => {
hass.mockWS("config/device_registry/list", () => data);
const devices = {};
data.forEach((device) => {
devices[device.id] = device;
});
hass.updateHass({ devices });
};

View File

@ -0,0 +1,7 @@
import { FloorRegistryEntry } from "../../../src/data/floor_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockFloorRegistry = (
hass: MockHomeAssistant,
data: FloorRegistryEntry[] = []
) => hass.mockWS("config/floor_registry/list", () => data);

View File

@ -0,0 +1,7 @@
import { LabelRegistryEntry } from "../../../src/data/label_registry";
import type { MockHomeAssistant } from "../../../src/fake_data/provide_hass";
export const mockLabelRegistry = (
hass: MockHomeAssistant,
data: LabelRegistryEntry[] = []
) => hass.mockWS("config/label_registry/list", () => data);

View File

@ -17,6 +17,10 @@ import { provideHass } from "../../../../src/fake_data/provide_hass";
import { ProvideHassElement } from "../../../../src/mixins/provide-hass-lit-mixin";
import type { HomeAssistant } from "../../../../src/types";
import "../../components/demo-black-white-row";
import { FloorRegistryEntry } from "../../../../src/data/floor_registry";
import { LabelRegistryEntry } from "../../../../src/data/label_registry";
import { mockFloorRegistry } from "../../../../demo/src/stubs/floor_registry";
import { mockLabelRegistry } from "../../../../demo/src/stubs/label_registry";
const ENTITIES = [
getEntity("alarm_control_panel", "alarm", "disarmed", {
@ -100,7 +104,7 @@ const DEVICES = [
const AREAS: AreaRegistryEntry[] = [
{
area_id: "backyard",
floor_id: null,
floor_id: "ground",
name: "Backyard",
icon: null,
picture: null,
@ -109,7 +113,7 @@ const AREAS: AreaRegistryEntry[] = [
},
{
area_id: "bedroom",
floor_id: null,
floor_id: "first",
name: "Bedroom",
icon: "mdi:bed",
picture: null,
@ -118,7 +122,7 @@ const AREAS: AreaRegistryEntry[] = [
},
{
area_id: "livingroom",
floor_id: null,
floor_id: "ground",
name: "Livingroom",
icon: "mdi:sofa",
picture: null,
@ -127,6 +131,45 @@ const AREAS: AreaRegistryEntry[] = [
},
];
const FLOORS: FloorRegistryEntry[] = [
{
floor_id: "ground",
name: "Ground floor",
level: 0,
icon: null,
aliases: [],
},
{
floor_id: "first",
name: "First floor",
level: 1,
icon: "mdi:numeric-1",
aliases: [],
},
{
floor_id: "second",
name: "Second floor",
level: 2,
icon: "mdi:numeric-2",
aliases: [],
},
];
const LABELS: LabelRegistryEntry[] = [
{
label_id: "energy",
name: "Energy",
icon: null,
color: "yellow",
},
{
label_id: "entertainment",
name: "Entertainment",
icon: "mdi:popcorn",
color: "blue",
},
];
const SCHEMAS: {
name: string;
input: Record<string, (BlueprintInput & { required?: boolean }) | null>;
@ -134,7 +177,12 @@ const SCHEMAS: {
{
name: "One of each",
input: {
label: { name: "Label", selector: { label: {} } },
floor: { name: "Floor", selector: { floor: {} } },
area: { name: "Area", selector: { area: {} } },
device: { name: "Device", selector: { device: {} } },
entity: { name: "Entity", selector: { entity: {} } },
target: { name: "Target", selector: { target: {} } },
state: {
name: "State",
selector: { state: { entity_id: "alarm_control_panel.alarm" } },
@ -143,15 +191,12 @@ const SCHEMAS: {
name: "Attribute",
selector: { attribute: { entity_id: "" } },
},
device: { name: "Device", selector: { device: {} } },
config_entry: {
name: "Integration",
selector: { config_entry: {} },
},
duration: { name: "Duration", selector: { duration: {} } },
addon: { name: "Addon", selector: { addon: {} } },
area: { name: "Area", selector: { area: {} } },
target: { name: "Target", selector: { target: {} } },
number_box: {
name: "Number Box",
selector: {
@ -300,6 +345,8 @@ const SCHEMAS: {
entity: { name: "Entity", selector: { entity: { multiple: true } } },
device: { name: "Device", selector: { device: { multiple: true } } },
area: { name: "Area", selector: { area: { multiple: true } } },
floor: { name: "Floor", selector: { floor: { multiple: true } } },
label: { name: "Label", selector: { label: { multiple: true } } },
select: {
name: "Select Multiple",
selector: {
@ -356,6 +403,8 @@ class DemoHaSelector extends LitElement implements ProvideHassElement {
mockDeviceRegistry(hass, DEVICES);
mockConfigEntries(hass);
mockAreaRegistry(hass, AREAS);
mockFloorRegistry(hass, FLOORS);
mockLabelRegistry(hass, LABELS);
mockHassioSupervisor(hass);
hass.mockWS("auth/sign_path", (params) => params);
hass.mockWS("media_player/browse_media", this._browseMedia);

View File

@ -274,7 +274,7 @@ export class HaFloorPicker extends SubscribeMixin(LitElement) {
if (areaIds) {
const floorAreaLookup = getFloorAreaLookup(areas);
outputFloors = outputFloors.filter((floor) =>
floorAreaLookup[floor.floor_id].some((area) =>
floorAreaLookup[floor.floor_id]?.some((area) =>
areaIds!.includes(area.area_id)
)
);

View File

@ -0,0 +1,169 @@
import { HassEntity } from "home-assistant-js-websocket";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
import type { HomeAssistant } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-floor-picker";
@customElement("ha-floors-picker")
export class HaFloorsPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ type: Array }) public value?: string[];
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: Boolean, attribute: "no-add" })
public noAdd = false;
/**
* Show only floors with entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show no floors with entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
/**
* Show only floors with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
@property({ attribute: false })
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ attribute: false })
public entityFilter?: (entity: HassEntity) => boolean;
@property({ attribute: "picked-floor-label" })
public pickedFloorLabel?: string;
@property({ attribute: "pick-floor-label" })
public pickFloorLabel?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
protected render() {
if (!this.hass) {
return nothing;
}
const currentFloors = this._currentFloors;
return html`
${currentFloors.map(
(floor) => html`
<div>
<ha-floor-picker
.curValue=${floor}
.noAdd=${this.noAdd}
.hass=${this.hass}
.value=${floor}
.label=${this.pickedFloorLabel}
.includeDomains=${this.includeDomains}
.excludeDomains=${this.excludeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.disabled=${this.disabled}
@value-changed=${this._floorChanged}
></ha-floor-picker>
</div>
`
)}
<div>
<ha-floor-picker
.noAdd=${this.noAdd}
.hass=${this.hass}
.label=${this.pickFloorLabel}
.helper=${this.helper}
.includeDomains=${this.includeDomains}
.excludeDomains=${this.excludeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.deviceFilter=${this.deviceFilter}
.entityFilter=${this.entityFilter}
.disabled=${this.disabled}
.placeholder=${this.placeholder}
.required=${this.required && !currentFloors.length}
@value-changed=${this._addFloor}
.excludeFloors=${currentFloors}
></ha-floor-picker>
</div>
`;
}
private get _currentFloors(): string[] {
return this.value || [];
}
private async _updateFloors(floors) {
this.value = floors;
fireEvent(this, "value-changed", {
value: floors,
});
}
private _floorChanged(ev: CustomEvent) {
ev.stopPropagation();
const curValue = (ev.currentTarget as any).curValue;
const newValue = ev.detail.value;
if (newValue === curValue) {
return;
}
const currentFloors = this._currentFloors;
if (!newValue || currentFloors.includes(newValue)) {
this._updateFloors(currentFloors.filter((ent) => ent !== curValue));
return;
}
this._updateFloors(
currentFloors.map((ent) => (ent === curValue ? newValue : ent))
);
}
private _addFloor(ev: CustomEvent) {
ev.stopPropagation();
const toAdd = ev.detail.value;
if (!toAdd) {
return;
}
(ev.currentTarget as any).value = "";
const currentFloors = this._currentFloors;
if (currentFloors.includes(toAdd)) {
return;
}
this._updateFloors([...currentFloors, toAdd]);
}
static override styles = css`
div {
margin-top: 8px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-floors-picker": HaFloorsPicker;
}
}

View File

@ -87,8 +87,12 @@ export class HaAreaSelector extends LitElement {
.label=${this.label}
.helper=${this.helper}
no-add
.deviceFilter=${this._filterDevices}
.entityFilter=${this._filterEntities}
.deviceFilter=${this.selector.area?.device
? this._filterDevices
: undefined}
.entityFilter=${this.selector.area?.entity
? this._filterEntities
: undefined}
.disabled=${this.disabled}
.required=${this.required}
></ha-area-picker>
@ -102,8 +106,12 @@ export class HaAreaSelector extends LitElement {
.helper=${this.helper}
.pickAreaLabel=${this.label}
no-add
.deviceFilter=${this._filterDevices}
.entityFilter=${this._filterEntities}
.deviceFilter=${this.selector.area?.device
? this._filterDevices
: undefined}
.entityFilter=${this.selector.area?.entity
? this._filterEntities
: undefined}
.disabled=${this.disabled}
.required=${this.required}
></ha-areas-picker>

View File

@ -0,0 +1,153 @@
import { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, PropertyValues, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import type { DeviceRegistryEntry } from "../../data/device_registry";
import { getDeviceIntegrationLookup } from "../../data/device_registry";
import { fireEvent } from "../../common/dom/fire_event";
import {
EntitySources,
fetchEntitySourcesWithCache,
} from "../../data/entity_sources";
import type { FloorSelector } from "../../data/selector";
import {
filterSelectorDevices,
filterSelectorEntities,
} from "../../data/selector";
import { HomeAssistant } from "../../types";
import "../ha-floor-picker";
import "../ha-floors-picker";
@customElement("ha-selector-floor")
export class HaFloorSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public selector!: FloorSelector;
@property() public value?: any;
@property() public label?: string;
@property() public helper?: string;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = true;
@state() private _entitySources?: EntitySources;
private _deviceIntegrationLookup = memoizeOne(getDeviceIntegrationLookup);
private _hasIntegration(selector: FloorSelector) {
return (
(selector.floor?.entity &&
ensureArray(selector.floor.entity).some(
(filter) => filter.integration
)) ||
(selector.floor?.device &&
ensureArray(selector.floor.device).some((device) => device.integration))
);
}
protected willUpdate(changedProperties: PropertyValues): void {
if (changedProperties.has("selector") && this.value !== undefined) {
if (this.selector.floor?.multiple && !Array.isArray(this.value)) {
this.value = [this.value];
fireEvent(this, "value-changed", { value: this.value });
} else if (!this.selector.floor?.multiple && Array.isArray(this.value)) {
this.value = this.value[0];
fireEvent(this, "value-changed", { value: this.value });
}
}
}
protected updated(changedProperties: PropertyValues): void {
if (
changedProperties.has("selector") &&
this._hasIntegration(this.selector) &&
!this._entitySources
) {
fetchEntitySourcesWithCache(this.hass).then((sources) => {
this._entitySources = sources;
});
}
}
protected render() {
if (this._hasIntegration(this.selector) && !this._entitySources) {
return nothing;
}
if (!this.selector.floor?.multiple) {
return html`
<ha-floor-picker
.hass=${this.hass}
.value=${this.value}
.label=${this.label}
.helper=${this.helper}
no-add
.deviceFilter=${this.selector.floor?.device
? this._filterDevices
: undefined}
.entityFilter=${this.selector.floor?.entity
? this._filterEntities
: undefined}
.disabled=${this.disabled}
.required=${this.required}
></ha-floor-picker>
`;
}
return html`
<ha-floors-picker
.hass=${this.hass}
.value=${this.value}
.helper=${this.helper}
.pickFloorLabel=${this.label}
no-add
.deviceFilter=${this.selector.floor?.device
? this._filterDevices
: undefined}
.entityFilter=${this.selector.floor?.entity
? this._filterEntities
: undefined}
.disabled=${this.disabled}
.required=${this.required}
></ha-floors-picker>
`;
}
private _filterEntities = (entity: HassEntity): boolean => {
if (!this.selector.floor?.entity) {
return true;
}
return ensureArray(this.selector.floor.entity).some((filter) =>
filterSelectorEntities(filter, entity, this._entitySources)
);
};
private _filterDevices = (device: DeviceRegistryEntry): boolean => {
if (!this.selector.floor?.device) {
return true;
}
const deviceIntegrations = this._entitySources
? this._deviceIntegrationLookup(
this._entitySources,
Object.values(this.hass.entities)
)
: undefined;
return ensureArray(this.selector.floor.device).some((filter) =>
filterSelectorDevices(filter, device, deviceIntegrations)
);
};
}
declare global {
interface HTMLElementTagNameMap {
"ha-selector-floor": HaFloorSelector;
}
}

View File

@ -30,6 +30,7 @@ const LOAD_ELEMENTS = {
entity: () => import("./ha-selector-entity"),
statistic: () => import("./ha-selector-statistic"),
file: () => import("./ha-selector-file"),
floor: () => import("./ha-selector-floor"),
label: () => import("./ha-selector-label"),
language: () => import("./ha-selector-language"),
navigation: () => import("./ha-selector-navigation"),

View File

@ -31,6 +31,7 @@ export type Selector =
| DateSelector
| DateTimeSelector
| DeviceSelector
| FloorSelector
| LegacyDeviceSelector
| DurationSelector
| EntitySelector
@ -170,6 +171,14 @@ export interface DeviceSelector {
} | null;
}
export interface FloorSelector {
floor: {
entity?: EntitySelectorFilter | readonly EntitySelectorFilter[];
device?: DeviceSelectorFilter | readonly DeviceSelectorFilter[];
multiple?: boolean;
} | null;
}
export interface LegacyDeviceSelector {
device: DeviceSelector["device"] & {
/**