diff --git a/src/components/knx-configure-entity-options.ts b/src/components/knx-configure-entity-options.ts new file mode 100644 index 00000000..e2a3090f --- /dev/null +++ b/src/components/knx-configure-entity-options.ts @@ -0,0 +1,93 @@ +import { html, nothing } from "lit"; + +import "@ha/components/ha-alert"; +import "@ha/components/ha-card"; +import "@ha/components/ha-expansion-panel"; +import "@ha/components/ha-selector/ha-selector-select"; +import "@ha/components/ha-selector/ha-selector-text"; +import "@ha/components/ha-settings-row"; +import type { HomeAssistant } from "@ha/types"; + +import "./knx-sync-state-selector-row"; +import "./knx-device-picker"; + +import { deviceFromIdentifier } from "../utils/device"; +import type { BaseEntityData, ErrorDescription } from "../types/entity_data"; + +export const renderConfigureEntityCard = ( + hass: HomeAssistant, + config: Partial, + updateConfig: (ev: CustomEvent) => void, + errors?: ErrorDescription[], +) => { + const device = config.device_info ? deviceFromIdentifier(hass, config.device_info) : undefined; + const deviceName = device ? device.name_by_user ?? device.name : ""; + // currently only baseError is possible, others shouldn't be possible due to selectors / optional + const entityBaseError = errors?.find((err) => (err.path ? err.path.length === 0 : true)); + + return html` + +

Entity configuration

+

Home Assistant specific settings.

+ ${errors + ? entityBaseError + ? html`` + : nothing + : nothing} + +
Device
+
A device allows to group multiple entities.
+ +
+ +
Name
+
Name of the entity.
+ +
+ + +
Entity settings
+
Description
+ +
+
+
+ `; +}; diff --git a/src/components/knx-configure-entity.ts b/src/components/knx-configure-entity.ts new file mode 100644 index 00000000..478ff395 --- /dev/null +++ b/src/components/knx-configure-entity.ts @@ -0,0 +1,304 @@ +import { css, html, LitElement, TemplateResult, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; + +import "@ha/components/ha-card"; +import "@ha/components/ha-control-select"; +import "@ha/components/ha-svg-icon"; +import "@ha/components/ha-expansion-panel"; +import "@ha/components/ha-selector/ha-selector"; +import "@ha/components/ha-settings-row"; + +import { fireEvent } from "@ha/common/dom/fire_event"; +import type { HomeAssistant } from "@ha/types"; + +import "./knx-group-address-selector"; +import "./knx-sync-state-selector-row"; +import { renderConfigureEntityCard } from "./knx-configure-entity-options"; +import { KNXLogger } from "../tools/knx-logger"; +import { extractValidationErrors } from "../utils/validation"; +import type { EntityData, ErrorDescription } from "../types/entity_data"; +import type { KNX } from "../types/knx"; +import type { PlatformInfo } from "../utils/common"; +import type { SettingsGroup, SelectorSchema, GroupSelect } from "../utils/schema"; + +const logger = new KNXLogger("knx-configure-entity"); + +@customElement("knx-configure-entity") +export class KNXConfigureEntity extends LitElement { + @property({ type: Object }) public hass!: HomeAssistant; + + @property({ attribute: false }) public knx!: KNX; + + @property({ type: Object }) public platform!: PlatformInfo; + + @property({ type: Object }) public config?: EntityData; + + @property({ type: Array }) public schema!: SettingsGroup[]; + + @property({ type: Array }) public validationErrors?: ErrorDescription[]; + + connectedCallback(): void { + super.connectedCallback(); + if (!this.config) { + // fill base keys to get better validation error messages + this.config = { entity: {}, knx: {} }; + } + } + + protected render(): TemplateResult | void { + const errors = extractValidationErrors(this.validationErrors, "data"); // "data" is root key in our python schema + return html` +
+

${this.platform.name}

+

${this.platform.description}

+
+ + +

KNX configuration

+ ${this.generateRootGroups(this.platform.schema, extractValidationErrors(errors, "knx"))} +
+ ${renderConfigureEntityCard( + this.hass, + this.config!.entity ?? {}, + this._updateConfig("entity"), + extractValidationErrors(errors, "entity"), + )} + `; + } + + generateRootGroups(schema: SettingsGroup[], errors?: ErrorDescription[]) { + const regular_items: SettingsGroup[] = []; + const advanced_items: SettingsGroup[] = []; + + schema.forEach((item: SettingsGroup) => { + if (item.advanced) { + advanced_items.push(item); + } else { + regular_items.push(item); + } + }); + return html` + ${regular_items.map((group: SettingsGroup) => this._generateSettingsGroup(group, errors))} + ${advanced_items.length + ? html` + ${advanced_items.map((group: SettingsGroup) => + this._generateSettingsGroup(group, errors), + )} + ` + : nothing} + `; + } + + _generateSettingsGroup(group: SettingsGroup, errors?: ErrorDescription[]) { + return html` +
${group.heading}
+
${group.description}
+ ${this._generateItems(group.selectors, errors)} +
`; + } + + _generateItems(selectors: SelectorSchema[], errors?: ErrorDescription[]) { + return html`${selectors.map((selector: SelectorSchema) => + this._generateItem(selector, errors), + )}`; + } + + _generateItem(selector: SelectorSchema, errors?: ErrorDescription[]) { + switch (selector.type) { + case "group_address": + return html` + + `; + case "selector": + // apply default value if available and no value is set + if (selector.default !== undefined && this.config!.knx[selector.name] == null) { + this.config!.knx[selector.name] = selector.default; + } + return html` + + `; + case "sync_state": + return html` + + `; + case "group_select": + return this._generateGroupSelect(selector, errors); + default: + logger.error("Unknown selector type", selector); + return nothing; + } + } + + _generateGroupSelect(selector: GroupSelect, errors?: ErrorDescription[]) { + const value: string = + this.config!.knx[selector.name] ?? + // set default if nothing is set yet + (this.config!.knx[selector.name] = selector.options[0].value); + const option = selector.options.find((item) => item.value === value); + if (option === undefined) { + logger.error("No option found for value", value); + } + return html` + ${option + ? html`

${option.description}

+
+ ${option.schema.map((item: SettingsGroup | SelectorSchema) => { + switch (item.type) { + case "settings_group": + return this._generateSettingsGroup(item, errors); + default: + return this._generateItem(item, errors); + } + })} +
` + : nothing}`; + } + + private _updateConfig(baseKey: string) { + return (ev) => { + ev.stopPropagation(); + if (!this.config[baseKey]) { + this.config[baseKey] = {}; + } + this.config[baseKey][ev.target.key] = ev.detail.value; + logger.debug(`update ${baseKey} key "${ev.target.key}" with "${ev.detail.value}"`); + fireEvent(this, "knx-entity-configuration-changed", this.config); + this.requestUpdate(); + }; + } + + static get styles() { + return css` + p { + color: var(--secondary-text-color); + } + + .header { + color: var(--ha-card-header-color, --primary-text-color); + font-family: var(--ha-card-header-font-family, inherit); + padding: 0 16px 16px; + + & h1 { + display: inline-flex; + align-items: center; + font-size: 26px; + letter-spacing: -0.012em; + line-height: 48px; + font-weight: normal; + margin-bottom: 14px; + + & ha-svg-icon { + color: var(--text-primary-color); + padding: 8px; + background-color: var(--blue-color); + border-radius: 50%; + margin-right: 8px; + } + } + + & p { + margin-top: -8px; + line-height: 24px; + } + } + + ::slotted(ha-alert) { + margin-top: 0 !important; + } + + ha-card { + margin-bottom: 24px; + padding: 16px; + + & .card-header { + display: inline-flex; + align-items: center; + } + } + + ha-settings-row { + padding: 0; + } + ha-control-select { + padding: 0; + margin-bottom: 16px; + } + + .group-description { + align-items: center; + margin-top: -8px; + padding-left: 8px; + padding-bottom: 8px; + } + + .group-selection { + padding-left: 16px; + padding-right: 16px; + & ha-settings-row:first-child { + border-top: 0; + } + } + + knx-group-address-selector, + ha-selector, + ha-selector-text, + ha-selector-select, + knx-sync-state-selector-row, + knx-device-picker { + display: block; + margin-bottom: 16px; + } + + ha-alert { + display: block; + margin: 20px auto; + max-width: 720px; + + & summary { + padding: 10px; + } + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "knx-configure-entity": KNXConfigureEntity; + } +} + +declare global { + // for fire event + interface HASSDomEvents { + "knx-entity-configuration-changed": EntityData; + } +} diff --git a/src/components/knx-device-picker.ts b/src/components/knx-device-picker.ts new file mode 100644 index 00000000..2603258e --- /dev/null +++ b/src/components/knx-device-picker.ts @@ -0,0 +1,237 @@ +/** This is a mix of ha-device-picker and ha-area-picker to allow for + * creation of new devices and include (KNX) devices without entities. + * Unlike the ha-device-picker or selector, its value is the device identifier + * (second tuple item), not the device id. + * */ +import { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; +import { LitElement, PropertyValues, html, nothing, TemplateResult } from "lit"; +import { customElement, query, property, state } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; + +import memoizeOne from "memoize-one"; + +import "@ha/components/ha-combo-box"; +import "@ha/components/ha-list-item"; + +import "../dialogs/knx-device-create-dialog"; + +import { fireEvent } from "@ha/common/dom/fire_event"; +import { ScorableTextItem, fuzzyFilterSort } from "@ha/common/string/filter/sequence-matching"; +import { stringCompare } from "@ha/common/string/compare"; + +import { HomeAssistant, ValueChangedEvent } from "@ha/types"; +import { AreaRegistryEntry } from "@ha/data/area_registry"; +import type { DeviceRegistryEntry } from "@ha/data/device_registry"; +import type { HaComboBox } from "@ha/components/ha-combo-box"; + +import { knxDevices, getKnxDeviceIdentifier } from "../utils/device"; + +interface Device { + name: string; + area: string; + id: string; + identifier?: string; +} + +type ScorableDevice = ScorableTextItem & Device; + +const rowRenderer: ComboBoxLitRenderer = (item) => + html` + ${item.name} + ${item.area} + `; + +@customElement("knx-device-picker") +class KnxDevicePicker extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public label?: string; + + @property() public value?: string; + + @state() private _opened?: boolean; + + @query("ha-combo-box", true) public comboBox!: HaComboBox; + + @state() private _showCreateDeviceDialog = false; + + // value is the knx identifier (device_info), not the device id + private _deviceId?: string; + + private _suggestion?: string; + + private _init = false; + + private _getDevices = memoizeOne( + ( + devices: DeviceRegistryEntry[], + areas: { [id: string]: AreaRegistryEntry }, + ): ScorableDevice[] => { + const outputDevices = devices.map((device) => { + const name = device.name_by_user ?? device.name ?? ""; + return { + id: device.id, + identifier: getKnxDeviceIdentifier(device), + name: name, + area: + device.area_id && areas[device.area_id] + ? areas[device.area_id].name + : this.hass.localize("ui.components.device-picker.no_area"), + strings: [name || ""], + }; + }); + return [ + { + id: "add_new", + name: "Add new device…", + area: "", + strings: [], + }, + ...outputDevices.sort((a, b) => + stringCompare(a.name || "", b.name || "", this.hass.locale.language), + ), + ]; + }, + ); + + private async _addDevice(device: DeviceRegistryEntry) { + const deviceEntries = [...knxDevices(this.hass), device]; + const devices = this._getDevices(deviceEntries, this.hass.areas); + this.comboBox.items = devices; + this.comboBox.filteredItems = devices; + await this.updateComplete; + await this.comboBox.updateComplete; + } + + public async open() { + await this.updateComplete; + await this.comboBox?.open(); + } + + public async focus() { + await this.updateComplete; + await this.comboBox?.focus(); + } + + protected updated(changedProps: PropertyValues) { + if ((!this._init && this.hass) || (this._init && changedProps.has("_opened") && this._opened)) { + this._init = true; + const devices = this._getDevices(knxDevices(this.hass), this.hass.areas); + const deviceId = this.value + ? devices.find((d) => d.identifier === this.value)?.id + : undefined; + this.comboBox.value = deviceId; + this._deviceId = deviceId; + this.comboBox.items = devices; + this.comboBox.filteredItems = devices; + } + } + + render(): TemplateResult { + return html` + + ${this._showCreateDeviceDialog ? this._renderCreateDeviceDialog() : nothing} + `; + } + + private _filterChanged(ev: CustomEvent): void { + const target = ev.target as HaComboBox; + const filterString = ev.detail.value; + if (!filterString) { + this.comboBox.filteredItems = this.comboBox.items; + return; + } + + const filteredItems = fuzzyFilterSort(filterString, target.items || []); + this._suggestion = filterString; + this.comboBox.filteredItems = [ + ...filteredItems, + { + id: "add_new_suggestion", + name: `Add new device '${this._suggestion}'`, + }, + ]; + } + + private _openedChanged(ev: ValueChangedEvent) { + this._opened = ev.detail.value; + } + + private _deviceChanged(ev: ValueChangedEvent) { + ev.stopPropagation(); + let newValue = ev.detail.value; + + if (newValue === "no_devices") { + newValue = ""; + } + + if (!["add_new_suggestion", "add_new"].includes(newValue)) { + if (newValue !== this._deviceId) { + this._setValue(newValue); + } + return; + } + + (ev.target as any).value = this._deviceId; + this._openCreateDeviceDialog(); + } + + private _setValue(deviceId: string | undefined) { + const device: Device | undefined = this.comboBox.items!.find((d) => d.id === deviceId); + const identifier = device?.identifier; + this.value = identifier; + this._deviceId = device?.id; + setTimeout(() => { + fireEvent(this, "value-changed", { value: identifier }); + fireEvent(this, "change"); + }, 0); + } + + private _renderCreateDeviceDialog() { + return html` + + `; + } + + private _openCreateDeviceDialog() { + this._showCreateDeviceDialog = true; + } + + private async _closeCreateDeviceDialog(ev: CustomEvent) { + const newDevice: DeviceRegistryEntry | undefined = ev.detail.newDevice; + if (newDevice) { + await this._addDevice(newDevice); + } else { + this.comboBox.setInputValue(""); + } + this._setValue(newDevice?.id); + this._suggestion = undefined; + this._showCreateDeviceDialog = false; + } +} + +declare global { + interface HTMLElementTagNameMap { + "knx-device-picker": KnxDevicePicker; + } +} diff --git a/src/components/knx-dpt-selector.ts b/src/components/knx-dpt-selector.ts new file mode 100644 index 00000000..060a21b3 --- /dev/null +++ b/src/components/knx-dpt-selector.ts @@ -0,0 +1,106 @@ +import { LitElement, html, css, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; + +import "@ha/components/ha-formfield"; +import { fireEvent } from "@ha/common/dom/fire_event"; + +import type { DPTOption } from "../utils/schema"; + +@customElement("knx-dpt-selector") +class KnxDptSelector extends LitElement { + @property({ type: Array }) public options!: DPTOption[]; + + @property() public value?: string; + + @property() public label?: string; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean, reflect: true }) public invalid = false; + + @property() public invalidMessage?: string; + + render() { + return html` +
+ ${this.label ?? nothing} + ${this.options.map( + (item: DPTOption) => html` +
+ + +
+ `, + )} + ${this.invalidMessage + ? html`

${this.invalidMessage}

` + : nothing} +
+ `; + } + + private _valueChanged(ev) { + ev.stopPropagation(); + const value = ev.target.value; + if (this.disabled || value === undefined || value === (this.value ?? "")) { + return; + } + fireEvent(this, "value-changed", { value: value }); + } + + static styles = [ + css` + :host([invalid]) div { + color: var(--error-color); + } + + .formfield { + display: flex; + align-items: center; + } + + label { + min-width: 200px; /* to make it easier to click */ + } + + p { + pointer-events: none; + color: var(--primary-text-color); + margin: 0px; + } + + .secondary { + padding-top: 4px; + font-family: var( + --mdc-typography-body2-font-family, + var(--mdc-typography-font-family, Roboto, sans-serif) + ); + -webkit-font-smoothing: antialiased; + font-size: var(--mdc-typography-body2-font-size, 0.875rem); + font-weight: var(--mdc-typography-body2-font-weight, 400); + line-height: normal; + color: var(--secondary-text-color); + } + + .invalid-message { + font-size: 0.75rem; + color: var(--error-color); + padding-left: 16px; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "knx-dpt-selector": KnxDptSelector; + } +} diff --git a/src/components/knx-group-address-selector.ts b/src/components/knx-group-address-selector.ts new file mode 100644 index 00000000..e542a81e --- /dev/null +++ b/src/components/knx-group-address-selector.ts @@ -0,0 +1,402 @@ +import { mdiChevronDown, mdiChevronUp } from "@mdi/js"; +import { LitElement, PropertyValues, html, css, nothing } from "lit"; +import { customElement, property, state, query, queryAll } from "lit/decorators"; +import { classMap } from "lit/directives/class-map"; +import { consume } from "@lit-labs/context"; + +import "@ha/components/ha-list-item"; +import "@ha/components/ha-selector/ha-selector-select"; +import "@ha/components/ha-icon-button"; +import { fireEvent } from "@ha/common/dom/fire_event"; +import type { HomeAssistant } from "@ha/types"; + +import "./knx-dpt-selector"; +import { dragDropContext, DragDropContext } from "../utils/drag-drop-context"; +import { isValidDPT } from "../utils/dpt"; +import { extractValidationErrors } from "../utils/validation"; +import type { GASchemaOptions } from "../utils/schema"; +import type { KNX } from "../types/knx"; +import type { DPT, GroupAddress } from "../types/websocket"; +import type { ErrorDescription, GASchema } from "../types/entity_data"; + +const getAddressOptions = ( + validGroupAddresses: GroupAddress[], +): { value: string; label: string }[] => + validGroupAddresses.map((groupAddress) => ({ + value: groupAddress.address, + label: `${groupAddress.address} - ${groupAddress.name}`, + })); + +@customElement("knx-group-address-selector") +export class GroupAddressSelector extends LitElement { + @consume({ context: dragDropContext, subscribe: true }) _dragDropContext?: DragDropContext; + + @property({ type: Object }) public hass!: HomeAssistant; + + @property({ type: Object }) public knx!: KNX; + + @property() public label?: string; + + @property({ type: Object }) public config: GASchema = {}; + + @property({ type: Object }) public options!: GASchemaOptions; + + @property({ reflect: true }) public key!: string; + + @property({ type: Array }) public validationErrors?: ErrorDescription[]; + + @state() private _showPassive = false; + + validGroupAddresses: GroupAddress[] = []; + + filteredGroupAddresses: GroupAddress[] = []; + + addressOptions: { value: string; label: string }[] = []; + + dptSelectorDisabled = false; + + private _validGADropTarget?: boolean; + + private _dragOverTimeout: { [key: string]: NodeJS.Timeout } = {}; + + @query(".passive") private _passiveContainer!: HTMLDivElement; + + @queryAll("ha-selector-select") private _gaSelectors!: NodeListOf; + + getValidGroupAddresses(validDPTs: DPT[]): GroupAddress[] { + return this.knx.project + ? Object.values(this.knx.project.knxproject.group_addresses).filter((groupAddress) => + groupAddress.dpt ? isValidDPT(groupAddress.dpt, validDPTs) : false, + ) + : []; + } + + getValidDptFromConfigValue(): DPT | undefined { + return this.config.dpt + ? this.options.dptSelect?.find((dpt) => dpt.value === this.config.dpt)?.dpt + : undefined; + } + + connectedCallback() { + super.connectedCallback(); + this.validGroupAddresses = this.getValidGroupAddresses( + this.options.validDPTs ?? this.options.dptSelect?.map((dptOption) => dptOption.dpt) ?? [], + ); + this.filteredGroupAddresses = this.validGroupAddresses; + this.addressOptions = getAddressOptions(this.filteredGroupAddresses); + } + + protected willUpdate(changedProps: PropertyValues) { + if (changedProps.has("config")) { + const selectedDPT = this.getValidDptFromConfigValue(); + if (changedProps.get("config")?.dpt !== this.config.dpt) { + this.filteredGroupAddresses = selectedDPT + ? this.getValidGroupAddresses([selectedDPT]) + : this.validGroupAddresses; + this.addressOptions = getAddressOptions(this.filteredGroupAddresses); + } + if (selectedDPT && this.knx.project?.project_loaded) { + const allDpts = [ + this.config.write, + this.config.state, + ...(this.config.passive ?? []), + ].filter((ga) => ga != null); + this.dptSelectorDisabled = + allDpts.length > 0 && + allDpts.every((ga) => { + const _dpt = this.knx.project?.knxproject.group_addresses[ga!]?.dpt; + return _dpt ? isValidDPT(_dpt, [selectedDPT]) : false; + }); + } else { + this.dptSelectorDisabled = false; + } + } + + this._validGADropTarget = this._dragDropContext?.groupAddress + ? this.filteredGroupAddresses.includes(this._dragDropContext.groupAddress) + : undefined; + } + + protected updated(changedProps: PropertyValues) { + if (!changedProps.has("validationErrors")) return; + this._gaSelectors.forEach(async (selector) => { + await selector.updateComplete; + const firstError = extractValidationErrors(this.validationErrors, selector.key)?.[0]; + // only ha-selector-select with custom_value or multiple have comboBox + selector.comboBox.errorMessage = firstError?.error_message; + selector.comboBox.invalid = !!firstError; + }); + } + + render() { + const alwaysShowPassive = this.config.passive && this.config.passive.length > 0; + + const validGADropTargetClass = this._validGADropTarget === true; + const invalidGADropTargetClass = this._validGADropTarget === false; + + return html` +
+
+ ${this.options.write + ? html`` + : nothing} + ${this.options.state + ? html`` + : nothing} +
+
+ +
+
+
+ +
+ ${this.options.dptSelect ? this._renderDptSelector() : nothing} + `; + } + + private _renderDptSelector() { + const invalid = extractValidationErrors(this.validationErrors, "dpt")?.[0]; + return html` + `; + } + + private _updateConfig(ev: CustomEvent) { + ev.stopPropagation(); + const target = ev.target as any; + const value = ev.detail.value; + const newConfig = { ...this.config, [target.key]: value }; + this._updateDptSelector(target.key, newConfig); + this.config = newConfig; + fireEvent(this, "value-changed", { value: this.config }); + this.requestUpdate(); + } + + private _updateDptSelector(targetKey: string, newConfig: GASchema) { + if (!(this.options.dptSelect && this.knx.project?.project_loaded)) return; + // updates newConfig in place + let newGa: string | undefined; + if (targetKey === "write" || targetKey === "state") { + newGa = newConfig[targetKey]; + } else if (targetKey === "passive") { + // for passive ignore removals, only use additions + const addedGa = newConfig.passive?.filter((ga) => !this.config.passive?.includes(ga))?.[0]; + newGa = addedGa; + } else { + return; + } + // disable when project is loaded and everything matches -> not here + if (!newConfig.write && !newConfig.state && !newConfig.passive?.length) { + // when all GAs have been cleared, reset dpt field + newConfig.dpt = undefined; + } + if (this.config.dpt === undefined) { + const newDpt = this.validGroupAddresses.find((ga) => ga.address === newGa)?.dpt; + if (!newDpt) return; + const exactDptMatch = this.options.dptSelect.find( + (dptOption) => dptOption.dpt.main === newDpt.main && dptOption.dpt.sub === newDpt.sub, + ); + const newDptValue = exactDptMatch + ? exactDptMatch.value + : // fallback to first valid DPT if allowed in options; otherwise undefined + this.options.dptSelect.find((dptOption) => isValidDPT(newDpt, [dptOption.dpt]))?.value; + newConfig.dpt = newDptValue; + } + } + + private _togglePassiveVisibility(ev: CustomEvent) { + ev.stopPropagation(); + ev.preventDefault(); + const newExpanded = !this._showPassive; + this._passiveContainer.style.overflow = "hidden"; + + const scrollHeight = this._passiveContainer.scrollHeight; + this._passiveContainer.style.height = `${scrollHeight}px`; + + if (!newExpanded) { + setTimeout(() => { + this._passiveContainer.style.height = "0px"; + }, 0); + } + this._showPassive = newExpanded; + } + + private _handleTransitionEnd() { + this._passiveContainer.style.removeProperty("height"); + this._passiveContainer.style.overflow = this._showPassive ? "initial" : "hidden"; + } + + private _dragOverHandler(ev: DragEvent) { + // dragEnter is immediately followed by dragLeave for unknown reason + // (I think some pointer events in the selectors shadow-dom) + // so we debounce dragOver to fake it + if (![...ev.dataTransfer.types].includes("text/group-address")) { + return; + } + ev.preventDefault(); + ev.dataTransfer.dropEffect = "move"; + + const target = ev.target as any; + if (this._dragOverTimeout[target.key]) { + clearTimeout(this._dragOverTimeout[target.key]); + } else { + // fake dragEnterHandler + target.classList.add("active-drop-zone"); + } + this._dragOverTimeout[target.key] = setTimeout(() => { + delete this._dragOverTimeout[target.key]; + // fake dragLeaveHandler + target.classList.remove("active-drop-zone"); + }, 100); + } + + private _dropHandler(ev: DragEvent) { + const ga = ev.dataTransfer.getData("text/group-address"); + if (!ga) { + return; + } + ev.stopPropagation(); + ev.preventDefault(); + const target = ev.target as any; + const newConfig = { ...this.config }; + if (target.selector.select.multiple) { + const newValues = [...(this.config[target.key] ?? []), ga]; + newConfig[target.key] = newValues; + } else { + newConfig[target.key] = ga; + } + this._updateDptSelector(target.key, newConfig); + fireEvent(this, "value-changed", { value: newConfig }); + // reset invalid state of textfield if set before drag + setTimeout(() => target.comboBox._inputElement.blur()); + } + + static styles = css` + .main { + display: flex; + flex-direction: row; + } + + .selectors { + flex: 1; + padding-right: 16px; + } + + .options { + width: 48px; + display: flex; + flex-direction: column-reverse; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; + } + + .passive { + overflow: hidden; + transition: height 150ms cubic-bezier(0.4, 0, 0.2, 1); + height: 0px; + margin-right: 64px; /* compensate for .options */ + } + + .passive.expanded { + height: auto; + } + + ha-selector-select { + display: block; + margin-bottom: 16px; + transition: + box-shadow 250ms, + opacity 250ms; + } + + .valid-drop-zone { + box-shadow: 0px 0px 5px 2px rgba(var(--rgb-primary-color), 0.5); + } + + .valid-drop-zone.active-drop-zone { + box-shadow: 0px 0px 5px 2px var(--primary-color); + } + + .invalid-drop-zone { + opacity: 0.5; + } + + .invalid-drop-zone.active-drop-zone { + box-shadow: 0px 0px 5px 2px var(--error-color); + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "knx-group-address-selector": GroupAddressSelector; + } +} diff --git a/src/components/knx-project-device-tree.ts b/src/components/knx-project-device-tree.ts new file mode 100644 index 00000000..6f17f03d --- /dev/null +++ b/src/components/knx-project-device-tree.ts @@ -0,0 +1,387 @@ +import { mdiNetworkOutline, mdiSwapHorizontalCircle, mdiArrowLeft, mdiDragVertical } from "@mdi/js"; +import { css, CSSResultGroup, html, LitElement, nothing, TemplateResult } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { repeat } from "lit/directives/repeat"; +import { consume } from "@lit-labs/context"; + +import "@ha/components/ha-svg-icon"; + +import { KNXProject, CommunicationObject, COFlags, DPT, GroupAddress } from "../types/websocket"; +import { KNXLogger } from "../tools/knx-logger"; +import { dragDropContext, type DragDropContext } from "../utils/drag-drop-context"; +import { filterValidComObjects } from "../utils/dpt"; +import { dptToString } from "../utils/format"; + +const logger = new KNXLogger("knx-project-device-tree"); + +interface DeviceTreeItem { + ia: string; + name: string; + manufacturer: string; + noChannelComObjects: CommunicationObject[]; + channels: Record; +} + +const gaDptString = (ga: GroupAddress) => { + const dpt = dptToString(ga.dpt); + return dpt ? `DPT ${dpt}` : ""; +}; + +const comObjectFlags = (flags: COFlags): string => + // – are en-dashes + `${flags.read ? "R" : "–"} ${flags.write ? "W" : "–"} ${flags.transmit ? "T" : "–"} ${ + flags.update ? "U" : "–" + }`; + +@customElement("knx-project-device-tree") +export class KNXProjectDeviceTree extends LitElement { + @consume({ context: dragDropContext }) _dragDropContext?: DragDropContext; + + @property({ attribute: false }) data!: KNXProject; + + @property({ attribute: false }) validDPTs?: DPT[]; + + @state() private _selectedDevice?: DeviceTreeItem; + + deviceTree: DeviceTreeItem[] = []; + + connectedCallback() { + super.connectedCallback(); + + const validCOs = this.validDPTs?.length + ? filterValidComObjects(this.data, this.validDPTs) + : this.data.communication_objects; + + const unfilteredDeviceTree = Object.values(this.data.devices).map((device) => { + const noChannelComObjects: CommunicationObject[] = []; + const channels = Object.fromEntries( + Object.entries(device.channels).map(([key, ch]) => [ + key, + { name: ch.name, comObjects: [] as CommunicationObject[] }, + ]), + ); + + for (const comObjectId of device.communication_object_ids) { + if (!(comObjectId in validCOs)) { + continue; + } + const comObject = validCOs[comObjectId]; + if (!comObject.channel) { + noChannelComObjects.push(comObject); + } else { + channels[comObject.channel].comObjects = ( + channels[comObject.channel].comObjects || [] + ).concat([comObject]); + } + } + // filter unused channels + const filteredChannels = Object.entries(channels).reduce( + (acc, [chId, ch]) => { + if (ch.comObjects.length) { + acc[chId] = ch; + } + return acc; + }, + {} as Record, + ); + + return { + ia: device.individual_address, + name: device.name, + manufacturer: device.manufacturer_name, + noChannelComObjects, + channels: filteredChannels, + }; + }); + + this.deviceTree = unfilteredDeviceTree.filter((deviceTreeItem) => { + if (deviceTreeItem.noChannelComObjects.length) { + return true; + } + if (Object.keys(deviceTreeItem.channels).length) { + return true; + } + return false; + }); + } + + protected render(): TemplateResult { + return html`
+ ${this._selectedDevice + ? this._renderSelectedDevice(this._selectedDevice) + : this._renderDevices()} +
`; + } + + private _renderDevices(): TemplateResult { + return html`
    + ${repeat( + this.deviceTree, + (device) => device.ia, + (device) => + html`
  • + ${this._renderDevice(device)} +
  • `, + )} +
`; + } + + private _renderDevice(device: DeviceTreeItem): TemplateResult { + // icon is rotated 90deg so mdiChevronDown -> left + return html`
+ + + ${device.ia} + +
+

${device.manufacturer}

+

${device.name}

+
+
`; + } + + private _renderSelectedDevice(device: DeviceTreeItem): TemplateResult { + return html`
    +
  • +
    + + ${this._renderDevice(device)} +
    +
  • + ${this._renderChannels(device)} +
`; + } + + private _renderChannels(device: DeviceTreeItem): TemplateResult { + return html`${this._renderComObjects(device.noChannelComObjects)} + ${repeat( + Object.entries(device.channels), + ([chId, _]) => `${device.ia}_ch_${chId}`, + ([_, channel]) => + !channel.comObjects.length + ? nothing // discard unused channels + : html`
  • ${channel.name}
  • + ${this._renderComObjects(channel.comObjects)}`, + )} `; + } + + private _renderComObjects(comObjects: CommunicationObject[]): TemplateResult { + return html`${repeat( + comObjects, + (comObject) => `${comObject.device_address}_co_${comObject.number}`, + (comObject) => + html`
  • +
    + ${comObject.number} +
    +

    + ${comObject.text}${comObject.function_text ? " - " + comObject.function_text : ""} +

    +

    ${comObjectFlags(comObject.flags)}

    +
    +
    +
      + ${this._renderGroupAddresses(comObject.group_address_links)} +
    +
  • `, + )} `; + } + + private _renderGroupAddresses(groupAddressLinks: string[]): TemplateResult { + const groupAddresses = groupAddressLinks.map((ga) => this.data.group_addresses[ga]); + return html`${repeat( + groupAddresses, + (groupAddress) => groupAddress.identifier, + (groupAddress) => + html`
  • +
    + + + ${groupAddress.address} + +
    +

    ${groupAddress.name}

    +

    ${gaDptString(groupAddress)}

    +
    +
    +
  • `, + )} `; + } + + private _selectDevice(ev: CustomEvent) { + const device = ev.target.device; + logger.debug("select device", device); + this._selectedDevice = device; + this.scrollTop = 0; + } + + static get styles(): CSSResultGroup { + return css` + :host { + display: block; + box-sizing: border-box; + margin: 0; + height: 100%; + overflow-y: scroll; + overflow-x: hidden; + background-color: var(--sidebar-background-color); + color: var(--sidebar-menu-button-text-color, --primary-text-color); + margin-right: env(safe-area-inset-right); + border-left: 1px solid var(--divider-color); + padding-left: 8px; + } + + ul { + list-style-type: none; + padding: 0; + margin-block-start: 8px; + } + + li { + display: block; + margin-bottom: 4px; + & div.item { + /* icon and text */ + display: flex; + align-items: center; + pointer-events: none; + & > div { + /* optional container for multiple paragraphs */ + min-width: 0; + width: 100%; + } + } + } + + li p { + margin: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } + + span.icon { + flex: 0 0 auto; + display: inline-flex; + /* align-self: stretch; */ + align-items: center; + + color: var(--text-primary-color); + font-size: 1rem; + font-weight: 700; + border-radius: 12px; + padding: 3px 6px; + margin-right: 4px; + + & > ha-svg-icon { + float: left; + width: 16px; + height: 16px; + margin-right: 4px; + } + + & > span { + /* icon text */ + flex: 1; + text-align: center; + } + } + + span.ia { + flex-basis: 70px; + background-color: var(--label-badge-grey); + & > ha-svg-icon { + transform: rotate(90deg); + } + } + + span.co { + flex-basis: 44px; + background-color: var(--amber-color); + } + + span.ga { + flex-basis: 54px; + background-color: var(--knx-green); + } + + .description { + margin-top: 4px; + margin-bottom: 4px; + } + + p.co-info, + p.ga-info { + font-size: 0.85rem; + font-weight: 300; + } + + .back-item { + margin-left: -8px; /* revert host padding to have gapless border */ + padding-left: 8px; + margin-top: -8px; /* revert ul margin-block-start to have gapless hover effect */ + padding-top: 8px; + padding-bottom: 8px; + border-bottom: 1px solid var(--divider-color); + margin-bottom: 8px; + } + + .back-icon { + margin-right: 8px; + color: var(--label-badge-grey); + } + + li.channel { + border-top: 1px solid var(--divider-color); + border-bottom: 1px solid var(--divider-color); + padding: 4px 16px; + font-weight: 500; + } + + li.clickable { + cursor: pointer; + } + li.clickable:hover { + background-color: rgba(var(--rgb-primary-text-color), 0.04); + } + + li[draggable="true"] { + cursor: grab; + } + li[draggable="true"]:hover { + border-radius: 12px; + background-color: rgba(var(--rgb-primary-color), 0.2); + } + + ul.group-addresses { + margin-top: 0; + margin-bottom: 8px; + + & > li:not(:first-child) { + /* passive addresses for this com-object */ + opacity: 0.8; + } + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "knx-project-device-tree": KNXProjectDeviceTree; + } +} diff --git a/src/components/knx-sync-state-selector-row.ts b/src/components/knx-sync-state-selector-row.ts new file mode 100644 index 00000000..f6071e91 --- /dev/null +++ b/src/components/knx-sync-state-selector-row.ts @@ -0,0 +1,118 @@ +import { css, html, LitElement, TemplateResult } from "lit"; +import { customElement, property } from "lit/decorators"; + +import { fireEvent } from "@ha/common/dom/fire_event"; +import "@ha/components/ha-selector/ha-selector-number"; +import "@ha/components/ha-selector/ha-selector-select"; +import type { HomeAssistant } from "@ha/types"; + +@customElement("knx-sync-state-selector-row") +export class KnxSyncStateSelectorRow extends LitElement { + @property({ type: Object }) public hass!: HomeAssistant; + + @property() public value: string | boolean = true; + + @property() public key = "sync_state"; + + @property({ type: Boolean }) noneValid = true; + + private _strategy: boolean | "init" | "expire" | "every" = true; + + private _minutes: number = 60; + + protected _hasMinutes(strategy: boolean | string): boolean { + return strategy === "expire" || strategy === "every"; + } + + protected willUpdate() { + if (typeof this.value === "boolean") { + this._strategy = this.value; + return; + } + const [strategy, minutes] = this.value.split(" "); + this._strategy = strategy; + if (+minutes) { + this._minutes = +minutes; + } + } + + protected render(): TemplateResult { + return html`
    + + + + +
    `; + } + + private _handleChange(ev) { + ev.stopPropagation(); + let strategy: boolean | string; + let minutes: number; + if (ev.target.key === "strategy") { + strategy = ev.detail.value; + minutes = this._minutes; + } else { + strategy = this._strategy; + minutes = ev.detail.value; + } + const value = this._hasMinutes(strategy) ? `${strategy} ${minutes}` : strategy; + fireEvent(this, "value-changed", { value }); + } + + static get styles() { + return css` + .inline { + width: 100%; + display: inline-flex; + flex-flow: row wrap; + gap: 16px; + justify-content: space-between; + } + + .inline > * { + flex: 1; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "knx-sync-state-selector-row": KnxSyncStateSelectorRow; + } +} diff --git a/src/dialogs/knx-device-create-dialog.ts b/src/dialogs/knx-device-create-dialog.ts new file mode 100644 index 00000000..f91ced40 --- /dev/null +++ b/src/dialogs/knx-device-create-dialog.ts @@ -0,0 +1,111 @@ +import "@material/mwc-button/mwc-button"; +import { LitElement, html, css } from "lit"; +import { customElement, property, state } from "lit/decorators"; + +import "@ha/components/ha-area-picker"; +import "@ha/components/ha-dialog"; +import "@ha/components/ha-selector/ha-selector-text"; + +import { fireEvent } from "@ha/common/dom/fire_event"; +import { haStyleDialog } from "@ha/resources/styles"; +import type { DeviceRegistryEntry } from "@ha/data/device_registry"; +import type { HomeAssistant } from "@ha/types"; + +import { createDevice } from "../services/websocket.service"; + +declare global { + // for fire event + interface HASSDomEvents { + "create-device-dialog-closed": { newDevice: DeviceRegistryEntry | undefined }; + } +} + +@customElement("knx-device-create-dialog") +class DeviceCreateDialog extends LitElement { + @property({ type: Object }) public hass!: HomeAssistant; + + @property() public deviceName?: string; + + @state() private area?: string; + + _deviceEntry?: DeviceRegistryEntry; + + public closeDialog(_ev) { + fireEvent( + this, + "create-device-dialog-closed", + { newDevice: this._deviceEntry }, + { bubbles: false }, + ); + } + + private _createDevice() { + createDevice(this.hass, { name: this.deviceName!, area_id: this.area }) + .then((resultDevice) => { + this._deviceEntry = resultDevice; + }) + .finally(() => { + this.closeDialog(undefined); + }); + } + + protected render() { + return html` + + + + + ${this.hass.localize("ui.common.cancel")} + + + ${this.hass.localize("ui.common.add")} + + `; + } + + protected _valueChanged(ev: CustomEvent) { + ev.stopPropagation(); + this[ev.target.key] = ev.detail.value; + } + + static get styles() { + return [ + haStyleDialog, + css` + @media all and (min-width: 600px) { + ha-dialog { + --mdc-dialog-min-width: 480px; + } + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "knx-device-create-dialog": DeviceCreateDialog; + } +} diff --git a/src/knx-router.ts b/src/knx-router.ts index 68720632..906c2df1 100644 --- a/src/knx-router.ts +++ b/src/knx-router.ts @@ -28,10 +28,15 @@ export const knxMainTabs: PageNavigation[] = [ path: `${BASE_URL}/project`, iconPath: mdiFileTreeOutline, }, + { + translationKey: "entities_view_title", + path: `${BASE_URL}/entities`, + iconPath: mdiFileTreeOutline, + }, ]; @customElement("knx-router") -class KnxRouter extends HassRouterPage { +export class KnxRouter extends HassRouterPage { @property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public knx!: KNX; @@ -45,6 +50,7 @@ class KnxRouter extends HassRouterPage { protected routerOptions: RouterOptions = { defaultPage: "info", + beforeRender: (page: string) => (page === "" ? this.routerOptions.defaultPage : undefined), routes: { info: { tag: "knx-info", @@ -67,6 +73,13 @@ class KnxRouter extends HassRouterPage { return import("./views/project_view"); }, }, + entities: { + tag: "knx-entities-router", + load: () => { + logger.debug("Importing knx-entities-view"); + return import("./views/entities_router"); + }, + }, }, }; diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index c65864d7..30c8ce82 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -18,6 +18,7 @@ "info_issue_tracker_knx_frontend": "the KNX frontend panel", "info_issue_tracker_xknxproject": "project data parsing", "info_issue_tracker_xknx": "KNX connection or DPT decoding", + "entities_view_title": "Entities", "group_monitor_title": "Group Monitor", "group_monitor_time": "Time", "group_monitor_direction": "Direction", @@ -39,6 +40,7 @@ "project_view_table_name": "Name", "project_view_table_description": "Description", "project_view_table_dpt": "DPT", + "project_view_add_switch": "Add switch", "Incoming": "Incoming", "Outgoing": "Outgoing" } diff --git a/src/main.ts b/src/main.ts index 8e22affa..1c62e61f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import { LitElement, html, nothing } from "lit"; +import { LitElement, css, html, nothing } from "lit"; import { customElement, property } from "lit/decorators"; import { applyThemesOnElement } from "@ha/common/dom/apply_themes_on_element"; @@ -36,10 +36,6 @@ class KnxFrontend extends knxElement { } this.addEventListener("knx-location-changed", (e) => this._setRoute(e as LocationChangedEvent)); - if (this.route.path === "" || this.route.path === "/") { - navigate("/knx/info", { replace: true }); - } - computeDirectionStyles(computeRTL(this.hass), this.parentElement as LitElement); listenMediaQuery("(prefers-color-scheme: dark)", (_matches) => { @@ -63,6 +59,19 @@ class KnxFrontend extends knxElement { `; } + static get styles() { + // apply "Settings" style toolbar color for `hass-subpage` + return css` + :host { + --app-header-background-color: var(--sidebar-background-color); + --app-header-text-color: var(--sidebar-text-color); + --app-header-border-bottom: 1px solid var(--divider-color); + --knx-green: #5e8a3a; + --knx-blue: #2a4691; + } + `; + } + private _setRoute(ev: LocationChangedEvent): void { if (!ev.detail?.route) { return; diff --git a/src/services/websocket.service.ts b/src/services/websocket.service.ts index 0f3a080c..69210386 100644 --- a/src/services/websocket.service.ts +++ b/src/services/websocket.service.ts @@ -1,10 +1,18 @@ import { HomeAssistant } from "@ha/types"; +import { ExtEntityRegistryEntry } from "@ha/data/entity_registry"; +import { DeviceRegistryEntry } from "@ha/data/device_registry"; import { KNXInfoData, TelegramDict, GroupMonitorInfoData, KNXProjectRespone, } from "../types/websocket"; +import { + CreateEntityData, + CreateEntityResult, + UpdateEntityData, + DeviceCreateData, +} from "../types/entity_data"; export const getKnxInfoData = (hass: HomeAssistant): Promise => hass.callWS({ @@ -44,3 +52,59 @@ export const getKnxProject = (hass: HomeAssistant): Promise = hass.callWS({ type: "knx/get_knx_project", }); + +/** + * Entity store calls. + */ +export const validateEntity = ( + hass: HomeAssistant, + entityData: CreateEntityData | UpdateEntityData, +): Promise => // CreateEntityResult.entity_id will be null when only validating + hass.callWS({ + type: "knx/validate_entity", + ...entityData, + }); + +export const createEntity = ( + hass: HomeAssistant, + entityData: CreateEntityData, +): Promise => + hass.callWS({ + type: "knx/create_entity", + ...entityData, + }); + +export const updateEntity = ( + hass: HomeAssistant, + entityData: UpdateEntityData, +): Promise => // CreateEntityResult.entity_id will be null when updating + hass.callWS({ + type: "knx/update_entity", + ...entityData, + }); + +export const deleteEntity = (hass: HomeAssistant, entityId: string) => + hass.callWS({ + type: "knx/delete_entity", + entity_id: entityId, + }); + +export const getEntityConfig = (hass: HomeAssistant, entityId: string): Promise => + hass.callWS({ + type: "knx/get_entity_config", + entity_id: entityId, + }); + +export const getEntityEntries = (hass: HomeAssistant): Promise => + hass.callWS({ + type: "knx/get_entity_entries", + }); + +export const createDevice = ( + hass: HomeAssistant, + deviceData: DeviceCreateData, +): Promise => + hass.callWS({ + type: "knx/create_device", + ...deviceData, + }); diff --git a/src/types/entity_data.ts b/src/types/entity_data.ts new file mode 100644 index 00000000..4624c2f9 --- /dev/null +++ b/src/types/entity_data.ts @@ -0,0 +1,66 @@ +export type entityCategory = "config" | "diagnostic"; + +export type supportedPlatform = "switch"; + +export interface GASchema { + write?: string; + state?: string; + passive?: string[]; + dpt?: string; +} + +export interface BaseEntityData { + device_info: string | null; + entity_category: entityCategory | null; + name: string; +} + +export interface SwitchEntityData { + entity: BaseEntityData; + invert: boolean; + respond_to_read: boolean; + ga_switch: GASchema; + sync_state: string | boolean; +} + +export type KnxEntityData = SwitchEntityData; + +export type EntityData = { + entity: BaseEntityData; + knx: KnxEntityData; +}; + +export interface CreateEntityData { + platform: supportedPlatform; + data: EntityData; +} + +export interface UpdateEntityData extends CreateEntityData { + entity_id: string; +} + +export interface DeviceCreateData { + name: string; + area_id?: string; +} + +// ################# +// Validation result +// ################# + +export interface ErrorDescription { + path: string[] | null; + error_message: string; + error_class: string; +} + +export type CreateEntityResult = + | { + success: true; + entity_id: string | null; + } + | { + success: false; + error_base: string; + errors: ErrorDescription[]; + }; diff --git a/src/types/websocket.ts b/src/types/websocket.ts index 45494d3d..92bc1dc8 100644 --- a/src/types/websocket.ts +++ b/src/types/websocket.ts @@ -43,6 +43,8 @@ export interface KNXProject { info: KNXProjectInfo; group_addresses: { [key: string]: GroupAddress }; group_ranges: { [key: string]: GroupRange }; + devices: { [key: string]: Device }; + communication_objects: { [key: string]: CommunicationObject }; } export interface GroupRange { @@ -70,3 +72,50 @@ export interface DPT { main: number; sub: number | null; } + +export interface Device { + name: string; + hardware_name: string; + description: string; + manufacturer_name: string; + individual_address: string; + application: string | null; + project_uid: number | null; + communication_object_ids: string[]; + channels: Record; // id: Channel +} + +export interface Channel { + identifier: string; + name: string; +} + +export interface CommunicationObject { + name: string; + number: number; + text: string; + function_text: string; + description: string; + device_address: string; + device_application: string | null; + module: ModuleInstanceInfos | null; + channel: string | null; + dpts: DPT[]; + object_size: string; + group_address_links: string[]; + flags: COFlags; +} + +interface ModuleInstanceInfos { + definition: string; + root_number: number; // `Number` assigned by ComObject - without Module base object number added +} + +export interface COFlags { + read: boolean; + write: boolean; + communication: boolean; + transmit: boolean; + update: boolean; + readOnInit: boolean; +} diff --git a/src/utils/common.ts b/src/utils/common.ts new file mode 100644 index 00000000..dc03aa65 --- /dev/null +++ b/src/utils/common.ts @@ -0,0 +1,29 @@ +import { mdiToggleSwitchVariant } from "@mdi/js"; +import { FIXED_DOMAIN_ICONS } from "@ha/common/const"; +import * as schema from "./schema"; + +export type PlatformInfo = { + name: string; + iconPath: string; + color: string; + description?: string; + schema: schema.SettingsGroup[]; +}; + +export const platformConstants: { [key: string]: PlatformInfo } = { + switch: { + name: "Switch", + iconPath: mdiToggleSwitchVariant, + color: "var(--blue-color)", + description: "The KNX switch platform is used as an interface to switching actuators.", + schema: schema.switchSchema, + }, + light: { + name: "Light", + iconPath: FIXED_DOMAIN_ICONS.light, + color: "var(--amber-color)", + description: + "The KNX light platform is used as an interface to dimming actuators, LED controllers, DALI gateways and similar.", + schema: schema.lightSchema, + }, +}; diff --git a/src/utils/device.ts b/src/utils/device.ts new file mode 100644 index 00000000..3d6dbf0d --- /dev/null +++ b/src/utils/device.ts @@ -0,0 +1,27 @@ +import { HomeAssistant } from "@ha/types"; +import type { DeviceRegistryEntry } from "@ha/data/device_registry"; + +const isKnxIdentifier = (identifier: [string, string]): boolean => identifier[0] === "knx"; + +const isKnxDevice = (device: DeviceRegistryEntry): boolean => + device.identifiers.some(isKnxIdentifier); + +export const knxDevices = (hass: HomeAssistant): DeviceRegistryEntry[] => + Object.values(hass.devices).filter(isKnxDevice); + +export const deviceFromIdentifier = ( + hass: HomeAssistant, + identifier: string, +): DeviceRegistryEntry | undefined => { + const deviceEntry = Object.values(hass.devices).find((entry) => + entry.identifiers.find( + (deviceIdentifier) => isKnxIdentifier(deviceIdentifier) && deviceIdentifier[1] === identifier, + ), + ); + return deviceEntry; +}; + +export const getKnxDeviceIdentifier = (deviceEntry: DeviceRegistryEntry): string | undefined => { + const knxIdentifier = deviceEntry.identifiers.find(isKnxIdentifier); + return knxIdentifier ? knxIdentifier[1] : undefined; +}; diff --git a/src/utils/dpt.ts b/src/utils/dpt.ts new file mode 100644 index 00000000..74196563 --- /dev/null +++ b/src/utils/dpt.ts @@ -0,0 +1,66 @@ +import type { DPT, KNXProject, CommunicationObject, GroupAddress } from "../types/websocket"; +import type { SettingsGroup } from "./schema"; + +export const equalDPT = (dpt1: DPT, dpt2: DPT): boolean => + dpt1.main === dpt2.main && dpt1.sub === dpt2.sub; + +export const isValidDPT = (testDPT: DPT, validDPTs: DPT[]): boolean => + // true if main and sub is equal to one validDPT or + // if main is equal to one validDPT where sub is `null` + validDPTs.some( + (testValidDPT) => + testDPT.main === testValidDPT.main && + (testValidDPT.sub ? testDPT.sub === testValidDPT.sub : true), + ); + +export const filterValidGroupAddresses = ( + project: KNXProject, + validDPTs: DPT[], +): { [id: string]: GroupAddress } => + Object.entries(project.group_addresses).reduce( + (acc, [id, groupAddress]) => { + if (groupAddress.dpt && isValidDPT(groupAddress.dpt, validDPTs)) { + acc[id] = groupAddress; + } + return acc; + }, + {} as { [id: string]: GroupAddress }, + ); + +export const filterValidComObjects = ( + project: KNXProject, + validDPTs: DPT[], +): { [id: string]: CommunicationObject } => { + const validGroupAddresses = filterValidGroupAddresses(project, validDPTs); + return Object.entries(project.communication_objects).reduce( + (acc, [id, comObject]) => { + if (comObject.group_address_links.some((gaLink) => gaLink in validGroupAddresses)) { + acc[id] = comObject; + } + return acc; + }, + {} as { [id: string]: CommunicationObject }, + ); +}; + +export const filterDupicateDPTs = (dpts: DPT[]): DPT[] => + dpts.reduce( + (acc, dpt) => (acc.some((resultDpt) => equalDPT(resultDpt, dpt)) ? acc : acc.concat([dpt])), + [] as DPT[], + ); + +export const validDPTsForSchema = (schema: SettingsGroup[]): DPT[] => { + const result: DPT[] = []; + schema.forEach((group) => { + group.selectors.forEach((selector) => { + if (selector.type === "group_address") { + if (selector.options.validDPTs) { + result.push(...selector.options.validDPTs); + } else if (selector.options.dptSelect) { + result.push(...selector.options.dptSelect.map((dptOption) => dptOption.dpt)); + } + } + }); + }); + return filterDupicateDPTs(result); +}; diff --git a/src/utils/drag-drop-context.ts b/src/utils/drag-drop-context.ts new file mode 100644 index 00000000..bdaaa81c --- /dev/null +++ b/src/utils/drag-drop-context.ts @@ -0,0 +1,62 @@ +import { createContext } from "@lit-labs/context"; +import { GroupAddress } from "../types/websocket"; +import { KNXLogger } from "../tools/knx-logger"; + +const logger = new KNXLogger("knx-drag-drop-context"); + +const contextKey = Symbol("drag-drop-context"); + +export class DragDropContext { + _groupAddress?: GroupAddress; + + _updateObservers: () => void; + + constructor(updateObservers: () => void) { + // call the context providers updateObservers method to trigger + // reactive updates from consumers drag events to other subscribed consumers + this._updateObservers = updateObservers; + } + + get groupAddress(): GroupAddress | undefined { + return this._groupAddress; + } + + // arrow function => so `this` refers to the class instance, not the event source + public gaDragStartHandler = (ev: DragEvent) => { + const target = ev.target as HTMLElement; + const ga = target.ga as GroupAddress; + if (!ga) { + logger.warn("dragstart: no 'ga' property found", target); + return; + } + this._groupAddress = ga; + logger.debug("dragstart", ga.address, this); + ev.dataTransfer?.setData("text/group-address", ga.address); + this._updateObservers(); + }; + + public gaDragEndHandler = (_ev: DragEvent) => { + logger.debug("dragend", this); + this._groupAddress = undefined; + this._updateObservers(); + }; + + public gaDragIndicatorStartHandler = (ev: MouseEvent) => { + const target = ev.target as HTMLElement; + const ga = target.ga as GroupAddress; + if (!ga) { + return; + } + this._groupAddress = ga; + logger.debug("drag indicator start", ga.address, this); + this._updateObservers(); + }; + + public gaDragIndicatorEndHandler = (_ev: MouseEvent) => { + logger.debug("drag indicator end", this); + this._groupAddress = undefined; + this._updateObservers(); + }; +} + +export const dragDropContext = createContext(contextKey); diff --git a/src/utils/schema.ts b/src/utils/schema.ts new file mode 100644 index 00000000..fc24dfd6 --- /dev/null +++ b/src/utils/schema.ts @@ -0,0 +1,432 @@ +import type { Selector } from "@ha/data/selector"; +import { DPT } from "../types/websocket"; + +export type SettingsGroup = { + type: "settings_group"; + heading: string; + description: string; + selectors: SelectorSchema[]; + advanced?: boolean; +}; + +export type SelectorSchema = + | GASchema + | GroupSelect + | { name: "sync_state"; type: "sync_state" } + | { + name: string; + type: "selector"; + default?: any; + selector: Selector; + label: string; + helper?: string; + }; + +type GASchema = { + name: string; + type: "group_address"; + label?: string; + options: GASchemaOptions; +}; + +export type GASchemaOptions = { + write?: { required: boolean }; + state?: { required: boolean }; + passive?: boolean; + validDPTs?: DPT[]; // one of validDPts or dptSelect shall be set + dptSelect?: DPTOption[]; +}; + +export type DPTOption = { + value: string; + label: string; + description?: string; + dpt: DPT; +}; + +export type GroupSelect = { + type: "group_select"; + name: string; + options: { + value: string; + label: string; + description?: string; + schema: (SettingsGroup | SelectorSchema)[]; + }[]; +}; + +export const switchSchema: SettingsGroup[] = [ + { + type: "settings_group", + heading: "Switching", + description: "DPT 1 group addresses controlling the switch function.", + selectors: [ + { + name: "ga_switch", + type: "group_address", + options: { + write: { required: true }, + state: { required: false }, + passive: true, + validDPTs: [{ main: 1, sub: null }], + }, + }, + { + name: "invert", + type: "selector", + selector: { boolean: null }, + label: "Invert", + helper: "Invert payloads before processing or sending.", + // default: false, // does this work? + }, + { + name: "respond_to_read", + type: "selector", + selector: { boolean: null }, + label: "Respond to read", + helper: "Respond to GroupValueRead telegrams received to the configured address.", + }, + ], + }, + { + type: "settings_group", + advanced: true, + heading: "State updater", + description: "Actively request state updates from KNX bus for state addresses.", + selectors: [ + { + name: "sync_state", + type: "sync_state", + }, + ], + }, +]; + +export const lightSchema: SettingsGroup[] = [ + { + type: "settings_group", + heading: "Switching", + description: "DPT 1 group addresses turning the light on or off.", + selectors: [ + { + name: "ga_switch", + type: "group_address", + options: { + write: { required: true }, + state: { required: false }, + passive: true, + validDPTs: [{ main: 1, sub: null }], + }, + }, + ], + }, + { + type: "settings_group", + heading: "Brightness", + description: "DPT 5 group addresses controlling the brightness.", + selectors: [ + { + name: "ga_brightness", + type: "group_address", + options: { + write: { required: false }, + state: { required: false }, + passive: true, + validDPTs: [{ main: 5, sub: 1 }], + }, + }, + ], + }, + { + type: "settings_group", + heading: "Color temperature", + description: "Control the lights color temperature.", + selectors: [ + { + name: "ga_color_temp", + type: "group_address", + options: { + write: { required: false }, + state: { required: false }, + passive: true, + dptSelect: [ + { + value: "5.001", + label: "Percent", + description: "DPT 5.001", + dpt: { main: 5, sub: 1 }, + }, + { + value: "7.600", + label: "Kelvin", + description: "DPT 7.600", + dpt: { main: 7, sub: 600 }, + }, + { + value: "9", + label: "2-byte float", + description: "DPT 9", + dpt: { main: 9, sub: null }, + }, + ], + }, + }, + { + name: "color_temp_min", + type: "selector", + label: "Warmest possible color temperature", + default: 2700, + selector: { + number: { + // color_temp selector doesn't provide a direct input box, only a slider + min: 1000, + max: 9000, + step: 1, + unit_of_measurement: "Kelvin", + }, + }, + }, + { + name: "color_temp_max", + type: "selector", + label: "Coldest possible color temperature", + default: 6000, + selector: { + number: { + min: 1000, + max: 9000, + step: 1, + unit_of_measurement: "Kelvin", + }, + }, + }, + ], + }, + { + type: "settings_group", + heading: "Color", + description: "Control the light color.", + selectors: [ + { + type: "group_select", + name: "_light_color_mode_schema", + options: [ + { + label: "Single address", + description: "RGB, RGBW or XYY color controlled by a single group address", + value: "default", + schema: [ + { + name: "ga_color", + type: "group_address", + options: { + write: { required: false }, + state: { required: false }, + passive: true, + dptSelect: [ + { + value: "232.600", + label: "RGB", + description: "DPT 232.600", + dpt: { main: 232, sub: 600 }, + }, + { + value: "251.600", + label: "RGBW", + description: "DPT 251.600", + dpt: { main: 251, sub: 600 }, + }, + { + value: "242.600", + label: "XYY", + description: "DPT 242.600", + dpt: { main: 242, sub: 600 }, + }, + ], + }, + }, + ], + }, + { + label: "Individual addresses", + description: "RGB(W) using individual state and brightness group addresses", + value: "individual", + schema: [ + { + type: "settings_group", + heading: "Red", + description: "Control the lights red color. Brightness group address is required.", + selectors: [ + { + name: "ga_red_switch", + type: "group_address", + label: "Switch", + options: { + write: { required: false }, + state: { required: false }, + passive: true, + validDPTs: [{ main: 1, sub: null }], + }, + }, + { + name: "ga_red_brightness", + type: "group_address", + label: "Brightness", + options: { + write: { required: false }, + state: { required: false }, + passive: true, + validDPTs: [{ main: 5, sub: 1 }], + }, + }, + ], + }, + { + type: "settings_group", + heading: "Green", + description: + "Control the lights green color. Brightness group address is required.", + selectors: [ + { + name: "ga_green_switch", + type: "group_address", + label: "Switch", + options: { + write: { required: false }, + state: { required: false }, + passive: true, + validDPTs: [{ main: 1, sub: null }], + }, + }, + { + name: "ga_green_brightness", + type: "group_address", + label: "Brightness", + options: { + write: { required: false }, + state: { required: false }, + passive: true, + validDPTs: [{ main: 5, sub: 1 }], + }, + }, + ], + }, + { + type: "settings_group", + heading: "Blue", + description: "Control the lights blue color. Brightness group address is required.", + selectors: [ + { + name: "ga_blue_switch", + type: "group_address", + label: "Switch", + options: { + write: { required: false }, + state: { required: false }, + passive: true, + validDPTs: [{ main: 1, sub: null }], + }, + }, + { + name: "ga_blue_brightness", + type: "group_address", + label: "Brightness", + options: { + write: { required: false }, + state: { required: false }, + passive: true, + validDPTs: [{ main: 5, sub: 1 }], + }, + }, + ], + }, + { + type: "settings_group", + heading: "White", + description: + "Control the lights white color. Brightness group address is required.", + selectors: [ + { + name: "ga_white_switch", + type: "group_address", + label: "Switch", + options: { + write: { required: false }, + state: { required: false }, + passive: true, + validDPTs: [{ main: 1, sub: null }], + }, + }, + { + name: "ga_white_brightness", + type: "group_address", + label: "Brightness", + options: { + write: { required: false }, + state: { required: false }, + passive: true, + validDPTs: [{ main: 5, sub: 1 }], + }, + }, + ], + }, + ], + }, + { + label: "HSV", + description: "Hue, saturation and brightness using individual group addresses", + value: "hsv", + schema: [ + { + type: "settings_group", + heading: "Hue", + description: "Control the lights hue.", + selectors: [ + { + name: "ga_hue", + type: "group_address", + options: { + write: { required: true }, + state: { required: false }, + passive: true, + validDPTs: [{ main: 5, sub: 1 }], + }, + }, + ], + }, + { + type: "settings_group", + heading: "Saturation", + description: "Control the lights saturation.", + selectors: [ + { + name: "ga_saturation", + type: "group_address", + options: { + write: { required: true }, + state: { required: false }, + passive: true, + validDPTs: [{ main: 5, sub: 1 }], + }, + }, + ], + }, + ], + }, + ], + }, + ], + }, + { + type: "settings_group", + advanced: true, + heading: "State updater", + description: "Actively request state updates from KNX bus for state addresses.", + selectors: [ + { + name: "sync_state", + type: "sync_state", + }, + ], + }, +]; diff --git a/src/utils/validation.ts b/src/utils/validation.ts new file mode 100644 index 00000000..ade899d4 --- /dev/null +++ b/src/utils/validation.ts @@ -0,0 +1,23 @@ +import type { ErrorDescription } from "../types/entity_data"; + +export const extractValidationErrors = ( + errors: ErrorDescription[] | undefined, + itemName: string, +): ErrorDescription[] | undefined => { + if (!errors) { + return undefined; + } + + const errorsForItem: ErrorDescription[] = []; + + for (const error of errors) { + if (error.path) { + const [pathHead, ...pathTail] = error.path; + if (pathHead === itemName) { + errorsForItem.push({ ...error, path: pathTail }); + } + } + } + + return errorsForItem.length ? errorsForItem : undefined; +}; diff --git a/src/views/entities_create.ts b/src/views/entities_create.ts new file mode 100644 index 00000000..98c95d02 --- /dev/null +++ b/src/views/entities_create.ts @@ -0,0 +1,407 @@ +import { mdiPlus, mdiFloppy } from "@mdi/js"; +import { LitElement, TemplateResult, PropertyValues, html, css, nothing } from "lit"; +import { customElement, property, state, query } from "lit/decorators"; +import { ContextProvider } from "@lit-labs/context"; + +import "@ha/layouts/hass-loading-screen"; +import "@ha/layouts/hass-subpage"; +import "@ha/components/ha-alert"; +import "@ha/components/ha-card"; +import "@ha/components/ha-fab"; +import "@ha/components/ha-svg-icon"; +import "@ha/components/ha-navigation-list"; +import { navigate } from "@ha/common/navigate"; +import { mainWindow } from "@ha/common/dom/get_main_window"; +import { fireEvent } from "@ha/common/dom/fire_event"; +import { throttle } from "@ha/common/util/throttle"; +import type { HomeAssistant, Route } from "@ha/types"; + +import "../components/knx-configure-entity"; +import "../components/knx-project-device-tree"; + +import { + createEntity, + updateEntity, + getEntityConfig, + validateEntity, +} from "services/websocket.service"; +import type { EntityData, ErrorDescription, CreateEntityResult } from "types/entity_data"; + +import { platformConstants } from "../utils/common"; +import { validDPTsForSchema } from "../utils/dpt"; +import { dragDropContext, DragDropContext } from "../utils/drag-drop-context"; +import { KNXLogger } from "../tools/knx-logger"; +import type { KNX } from "../types/knx"; +import type { PlatformInfo } from "../utils/common"; + +const logger = new KNXLogger("knx-create-entity"); + +@customElement("knx-create-entity") +export class KNXCreateEntity extends LitElement { + @property({ type: Object }) public hass!: HomeAssistant; + + @property({ attribute: false }) public knx!: KNX; + + @property({ type: Object }) public route!: Route; + + @property({ type: Boolean, reflect: true }) public narrow!: boolean; + + @property({ type: String, attribute: "back-path" }) public backPath?: string; + + @state() private _config?: EntityData; + + @state() private _loading = false; + + @state() private _validationErrors?: ErrorDescription[]; + + @state() private _validationBaseError?: string; + + @query("ha-alert") private _alertElement!: HTMLDivElement; + + private _intent?: "create" | "edit"; + + private entityPlatform?: string; + + private entityId?: string; // only used for "edit" intent + + private _dragDropContextProvider = new ContextProvider(this, { + context: dragDropContext, + initialValue: new DragDropContext(() => { + this._dragDropContextProvider.updateObservers(); + }), + }); + + protected firstUpdated() { + if (!this.knx.project) { + this.knx.loadProject().then(() => { + this.requestUpdate(); + }); + } + } + + protected willUpdate(changedProperties: PropertyValues) { + if (changedProperties.has("route")) { + const intent = this.route.prefix.split("/").at(-1); + if (intent === "create" || intent === "edit") { + this._intent = intent; + } else { + logger.error("Unknown intent", intent); + this._intent = undefined; + return; + } + + if (intent === "create") { + // knx/entities/create -> path: ""; knx/entities/create/ -> path: "/" + // knx/entities/create/light -> path: "/light" + const entityPlatform = this.route.path.split("/")[1]; + this.entityPlatform = entityPlatform; + this._loading = false; + } else if (intent === "edit") { + // knx/entities/edit/light.living_room -> path: "/light.living_room" + this.entityId = this.route.path.split("/")[1]; + this._loading = true; + getEntityConfig(this.hass, this.entityId) + .then((entityConfigData) => { + const { platform: entityPlatform, data: config } = entityConfigData; + this.entityPlatform = entityPlatform; + this._config = config; + }) + .catch((err) => { + logger.warn("Fetching entity config failed.", err); + this.entityPlatform = undefined; // used as error marker + }) + .finally(() => { + this._loading = false; + }); + } + // const urlParams = new URLSearchParams(mainWindow.location.search); + // const referrerGA = urlParams.get("ga"); + // console.log(referrerGA); + } + } + + protected render(): TemplateResult { + if (!this.hass || !this.knx.project || !this._intent || this._loading) { + return html` `; + } + if (this._intent === "edit") return this._renderEdit(); + return this._renderCreate(); + } + + private _renderCreate(): TemplateResult { + if (!this.entityPlatform) { + return this._renderTypeSelection(); + } + const platformInfo = platformConstants[this.entityPlatform]; + if (!platformInfo) { + logger.error("Unknown platform", this.entityPlatform); + return this._renderTypeSelection(); + } + return this._renderEntityConfig(platformInfo, true); + } + + private _renderEdit(): TemplateResult { + if (!this.entityPlatform) { + return this._renderNotFound(); + } + const platformInfo = platformConstants[this.entityPlatform]; + if (!platformInfo) { + logger.error("Unknown platform", this.entityPlatform); + return this._renderNotFound(); + } + return this._renderEntityConfig(platformInfo, false); + } + + private _renderNotFound(): TemplateResult { + return html` + +
    + Entity not found: ${this.entityId} +
    +
    + `; + } + + private _renderTypeSelection(): TemplateResult { + return html` + +
    + + + ({ + name: platformInfo.name, + description: platformInfo.description, + iconPath: platformInfo.iconPath, + iconColor: platformInfo.color, + path: `/knx/entities/create/${platform}`, + }))} + hasSecondary + .label=${"Select entity type"} + > + +
    +
    + `; + } + + private _renderEntityConfig(platformInfo: PlatformInfo, create: boolean): TemplateResult { + return html` +
    +
    + + ${this._validationBaseError + ? html` +
    + Validation error +

    Base error: ${this._validationBaseError}

    + ${this._validationErrors?.map( + (err) => + html`

    + ${err.error_class}: ${err.error_message} in ${err.path?.join(" / ")} +

    `, + ) ?? nothing} +
    +
    ` + : nothing} +
    + + + +
    + ${this.knx.project + ? html`
    + +
    ` + : nothing} +
    +
    `; + } + + private _configChanged(ev) { + ev.stopPropagation(); + logger.warn("configChanged", ev.detail); + this._config = ev.detail; + if (this._validationErrors) { + this._entityValidate(); + } + } + + private _entityValidate = throttle(() => { + logger.debug("validate", this._config); + if (this._config === undefined || this.entityPlatform === undefined) return; + validateEntity(this.hass, { platform: this.entityPlatform, data: this._config }).then( + (createEntityResult) => { + this._handleValidationError(createEntityResult, false); + }, + ); + }, 250); + + private _entityCreate(ev) { + ev.stopPropagation(); + if (this._config === undefined || this.entityPlatform === undefined) { + logger.error("No config found."); + return; + } + createEntity(this.hass, { platform: this.entityPlatform, data: this._config }) + .then((createEntityResult) => { + if (this._handleValidationError(createEntityResult, true)) return; + logger.debug("Successfully created entity", createEntityResult.entity_id); + navigate("/knx/entities", { replace: true }); + if (!createEntityResult.entity_id) { + logger.error("entity_id not found after creation."); + return; + } + this._entityMoreInfoSettings(createEntityResult.entity_id); + }) + .catch((err) => { + logger.error("Error creating entity", err); + }); + } + + private _entityUpdate(ev) { + ev.stopPropagation(); + if ( + this._config === undefined || + this.entityId === undefined || + this.entityPlatform === undefined + ) { + logger.error("No config found."); + return; + } + updateEntity(this.hass, { + platform: this.entityPlatform, + entity_id: this.entityId, + data: this._config, + }) + .then((createEntityResult) => { + if (this._handleValidationError(createEntityResult, true)) return; + logger.debug("Successfully updated entity", this.entityId); + navigate("/knx/entities", { replace: true }); + }) + .catch((err) => { + logger.error("Error updating entity", err); + }); + } + + private _handleValidationError(result: CreateEntityResult, final: boolean): boolean { + // return true if validation error; scroll to alert if final + if (result.success === false) { + logger.warn("Validation error", result); + this._validationErrors = result.errors; + this._validationBaseError = result.error_base; + if (final) { + setTimeout(() => this._alertElement.scrollIntoView({ behavior: "smooth" })); + } + return true; + } + this._validationErrors = undefined; + this._validationBaseError = undefined; + logger.debug("Validation passed", result.entity_id); + return false; + } + + private _entityMoreInfoSettings(entityId: string) { + fireEvent(mainWindow.document.querySelector("home-assistant")!, "hass-more-info", { + entityId, + view: "settings", + }); + } + + static get styles() { + return css` + hass-loading-screen { + --app-header-background-color: var(--sidebar-background-color); + --app-header-text-color: var(--sidebar-text-color); + } + + .type-selection { + margin: 20px auto 80px; + max-width: 720px; + } + + .content { + display: flex; + flex-direction: row; + height: 100%; + width: 100%; + + & > .entity-config { + flex-grow: 1; + flex-shrink: 1; + height: 100%; + overflow-y: scroll; + } + + & > .panel { + flex-grow: 0; + flex-shrink: 3; + width: 480px; + min-width: 280px; + } + } + + knx-configure-entity { + display: block; + margin: 20px auto 40px; /* leave 80px space for fab */ + max-width: 720px; + } + + ha-alert { + display: block; + margin: 20px auto; + max-width: 720px; + + & summary { + padding: 10px; + } + } + + ha-fab { + /* not slot="fab" to move out of panel */ + float: right; + margin-right: calc(16px + env(safe-area-inset-right)); + margin-bottom: 40px; + z-index: 1; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "knx-create-entity": KNXCreateEntity; + } +} diff --git a/src/views/entities_router.ts b/src/views/entities_router.ts new file mode 100644 index 00000000..835e2b85 --- /dev/null +++ b/src/views/entities_router.ts @@ -0,0 +1,45 @@ +import { customElement } from "lit/decorators"; + +import { RouterOptions } from "@ha/layouts/hass-router-page"; + +import { KnxRouter } from "../knx-router"; +import { KNXLogger } from "../tools/knx-logger"; + +const logger = new KNXLogger("router"); + +@customElement("knx-entities-router") +class KnxEntitiesRouter extends KnxRouter { + protected routerOptions: RouterOptions = { + defaultPage: "view", + beforeRender: (page: string) => (page === "" ? this.routerOptions.defaultPage : undefined), + routes: { + view: { + tag: "knx-entities-view", + load: () => { + logger.debug("Importing knx-entities-view"); + return import("./entities_view"); + }, + }, + create: { + tag: "knx-create-entity", + load: () => { + logger.debug("Importing knx-create-entity"); + return import("./entities_create"); + }, + }, + edit: { + tag: "knx-create-entity", + load: () => { + logger.debug("Importing knx-create-entity"); + return import("./entities_create"); + }, + }, + }, + }; +} + +declare global { + interface HTMLElementTagNameMap { + "knx-entities-router": KnxEntitiesRouter; + } +} diff --git a/src/views/entities_view.ts b/src/views/entities_view.ts new file mode 100644 index 00000000..e93c8593 --- /dev/null +++ b/src/views/entities_view.ts @@ -0,0 +1,262 @@ +import { mdiDelete, mdiInformationSlabCircleOutline, mdiPlus, mdiPencilOutline } from "@mdi/js"; +import { LitElement, TemplateResult, html, css } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import { ifDefined } from "lit/directives/if-defined"; + +import { HassEntity } from "home-assistant-js-websocket"; +import memoize from "memoize-one"; + +import "@ha/layouts/hass-loading-screen"; +import "@ha/layouts/hass-tabs-subpage"; +import "@ha/components/ha-card"; +import "@ha/components/ha-fab"; +import "@ha/components/ha-icon-button"; +import "@ha/components/ha-icon-overflow-menu"; +import "@ha/components/ha-state-icon"; +import "@ha/components/ha-svg-icon"; +import "@ha/components/data-table/ha-data-table"; +import { navigate } from "@ha/common/navigate"; +import { mainWindow } from "@ha/common/dom/get_main_window"; +import { fireEvent } from "@ha/common/dom/fire_event"; +import type { DataTableColumnContainer } from "@ha/components/data-table/ha-data-table"; +import { AreaRegistryEntry } from "@ha/data/area_registry"; +import { ExtEntityRegistryEntry } from "@ha/data/entity_registry"; +import { showAlertDialog, showConfirmationDialog } from "@ha/dialogs/generic/show-dialog-box"; +import type { PageNavigation } from "@ha/layouts/hass-tabs-subpage"; +import { HomeAssistant, Route } from "@ha/types"; + +import "../components/knx-project-tree-view"; + +import { getEntityEntries, deleteEntity } from "../services/websocket.service"; +import { KNX } from "../types/knx"; +import { KNXLogger } from "../tools/knx-logger"; + +const logger = new KNXLogger("knx-entities-view"); + +export interface EntityRow extends ExtEntityRegistryEntry { + entityState?: HassEntity; + area?: AreaRegistryEntry; +} + +@customElement("knx-entities-view") +export class KNXEntitiesView extends LitElement { + @property({ type: Object }) public hass!: HomeAssistant; + + @property({ attribute: false }) public knx!: KNX; + + @property({ type: Boolean, reflect: true }) public narrow!: boolean; + + @property({ type: Object }) public route?: Route; + + @property({ type: Array, reflect: false }) public tabs!: PageNavigation[]; + + @state() private knx_entities: EntityRow[] = []; + + @state() private filterDevice: string | null = null; + + protected firstUpdated() { + this._fetchEntities(); + } + + protected willUpdate() { + const urlParams = new URLSearchParams(mainWindow.location.search); + this.filterDevice = urlParams.get("device_id"); + } + + private async _fetchEntities() { + const entries = await getEntityEntries(this.hass); + logger.debug(`Fetched ${entries.length} entity entries.`); + this.knx_entities = entries.map((entry) => { + const entityState = this.hass.states[entry.entity_id]; + const device = entry.device_id ? this.hass.devices[entry.device_id] : undefined; + const areaId = entry.area_id ?? device?.area_id; + const area = areaId ? this.hass.areas[areaId] : undefined; + return { + ...entry, + entityState, + area, + }; + }); + } + + private _columns = memoize((_narrow, _language): DataTableColumnContainer => { + const iconWidth = "56px"; + const actionWidth = "176px"; // 48px*3 + 16px*2 padding + const textColumnWith = `calc((100% - ${iconWidth} - ${actionWidth}) / 4)`; + + return { + icon: { + title: "", + width: iconWidth, + type: "icon", + template: (entry) => html` + + `, + }, + friendly_name: { + filterable: true, + sortable: true, + title: "Friendly Name", + width: textColumnWith, + template: (entry) => entry.entityState?.attributes.friendly_name ?? "", + }, + entity_id: { + filterable: true, + sortable: true, + title: "Entity ID", + width: textColumnWith, + // template: (entry) => entry.entity_id, + }, + device: { + filterable: true, + sortable: true, + title: "Device", + width: textColumnWith, + template: (entry) => (entry.device_id ? this.hass.devices[entry.device_id].name ?? "" : ""), + }, + device_id: { + hidden: true, // for filtering only + title: "Device ID", + filterable: true, + template: (entry) => entry.device_id ?? "", + }, + area: { + title: "Area", + sortable: true, + filterable: true, + width: textColumnWith, + template: (entry) => entry.area?.name ?? "", + }, + actions: { + title: "", + width: actionWidth, + type: "icon-button", + template: (entry) => html` + + + + `, + }, + }; + }); + + private _entityEdit = (ev: Event) => { + ev.stopPropagation(); + const entry = ev.target.entityEntry as EntityRow; + navigate("/knx/entities/edit/" + entry.entity_id); + }; + + private _entityMoreInfo = (ev: Event) => { + ev.stopPropagation(); + const entry = ev.target.entityEntry as EntityRow; + fireEvent(mainWindow.document.querySelector("home-assistant")!, "hass-more-info", { + entityId: entry.entity_id, + }); + }; + + private _entityDelete = (ev: Event) => { + ev.stopPropagation(); + const entry = ev.target.entityEntry as EntityRow; + showConfirmationDialog(this, { + text: `${this.hass.localize("ui.common.delete")} ${entry.entity_id}?`, + }).then((confirmed) => { + if (confirmed) { + deleteEntity(this.hass, entry.entity_id) + .then(() => { + logger.debug("entity deleted", entry.entity_id); + this._fetchEntities(); + }) + .catch((err: any) => { + showAlertDialog(this, { + title: "Deletion failed", + text: err, + }); + }); + } + }); + }; + + protected render(): TemplateResult | void { + if (!this.hass || !this.knx_entities) { + return html` `; + } + + return html` + +
    + +
    + + + +
    + `; + } + + private _entityCreate() { + navigate("/knx/entities/create"); + } + + static get styles() { + return css` + hass-loading-screen { + --app-header-background-color: var(--sidebar-background-color); + --app-header-text-color: var(--sidebar-text-color); + } + .sections { + display: flex; + flex-direction: row; + height: 100%; + } + + .entity-table { + flex: 1; + } + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "knx-entities-view": KNXEntitiesView; + } +} diff --git a/src/views/info.ts b/src/views/info.ts index 58cd522c..c1e71a91 100644 --- a/src/views/info.ts +++ b/src/views/info.ts @@ -241,7 +241,6 @@ export class KNXInfo extends LitElement { showAlertDialog(this, { title: "Upload failed", text: extractApiErrorMessage(err), - confirmText: "ok", }); } finally { if (!error) { @@ -268,7 +267,6 @@ export class KNXInfo extends LitElement { showAlertDialog(this, { title: "Deletion failed", text: extractApiErrorMessage(err), - confirmText: "ok", }); } finally { this.loadKnxInfo(); diff --git a/src/views/project_view.ts b/src/views/project_view.ts index 1d8486d6..b96058d1 100644 --- a/src/views/project_view.ts +++ b/src/views/project_view.ts @@ -1,15 +1,18 @@ -import { mdiFilterVariant } from "@mdi/js"; +import { mdiFilterVariant, mdiPlus } from "@mdi/js"; import { LitElement, TemplateResult, html, css, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; import memoize from "memoize-one"; import { HASSDomEvent } from "@ha/common/dom/fire_event"; +import { navigate } from "@ha/common/navigate"; import "@ha/layouts/hass-loading-screen"; import "@ha/layouts/hass-tabs-subpage"; import type { PageNavigation } from "@ha/layouts/hass-tabs-subpage"; import "@ha/components/ha-card"; import "@ha/components/ha-icon-button"; +import "@ha/components/ha-icon-overflow-menu"; +import type { IconOverflowMenuItem } from "@ha/components/ha-icon-overflow-menu"; import "@ha/components/data-table/ha-data-table"; import type { DataTableColumnContainer } from "@ha/components/data-table/ha-data-table"; @@ -63,9 +66,10 @@ export class KNXProjectView extends LitElement { this._groupRangeAvailable = compare(projectVersion, MIN_XKNXPROJECT_VERSION, ">="); } - private _columns = memoize((narrow, _language): DataTableColumnContainer => { + private _columns = memoize((_narrow, _language): DataTableColumnContainer => { const addressWidth = "100px"; const dptWidth = "82px"; + const overflowMenuWidth = "72px"; return { address: { @@ -78,16 +82,7 @@ export class KNXProjectView extends LitElement { filterable: true, sortable: true, title: this.knx.localize("project_view_table_name"), - width: narrow - ? "calc(100% - " + dptWidth + " - " + addressWidth + ")" - : "calc(50% - " + dptWidth + ")", - }, - description: { - filterable: true, - sortable: true, - hidden: narrow, - title: this.knx.localize("project_view_table_description"), - width: "calc(50% - " + addressWidth + ")", + width: `calc(100% - ${dptWidth} - ${addressWidth} - ${overflowMenuWidth})`, }, dpt: { sortable: true, @@ -101,9 +96,39 @@ export class KNXProjectView extends LitElement { >${ga.dpt.sub ? "." + ga.dpt.sub.toString().padStart(3, "0") : ""} ` : "", }, + actions: { + title: "", + width: overflowMenuWidth, + type: "overflow-menu", + template: (ga: GroupAddress) => this._groupAddressMenu(ga), + }, }; }); + private _groupAddressMenu(groupAddress: GroupAddress): TemplateResult | typeof nothing { + const items: IconOverflowMenuItem[] = []; + if (groupAddress.dpt?.main === 1) { + items.push({ + path: mdiPlus, + label: this.knx.localize("project_view_add_switch"), + action: () => { + navigate("/knx/entities/create?ga=" + groupAddress.address); + }, + }); + // items.push({ + // path: mdiPlus, + // label: "Add binary sensor", + // action: () => logger.warn(groupAddress.address), + // }); + } + + return items.length + ? html` + + ` + : nothing; + } + private _getRows(visibleGroupAddresses: string[]): GroupAddress[] { if (!visibleGroupAddresses.length) // if none is set, default to show all