diff --git a/eslint.config.mjs b/eslint.config.mjs index f43f1d9c..6d740049 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -51,6 +51,8 @@ export default tseslint.config( sourceType: "module", parserOptions: { + project: "./tsconfig.json", + tsconfigRootDir: _dirname, ecmaFeatures: { modules: true, }, diff --git a/src/components/knx-dpt-dialog-selector.ts b/src/components/knx-dpt-dialog-selector.ts new file mode 100644 index 00000000..95e9b9a4 --- /dev/null +++ b/src/components/knx-dpt-dialog-selector.ts @@ -0,0 +1,189 @@ +import { LitElement, html, css, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; + +import "@ha/components/ha-icon-button"; +import { mdiClose, mdiMenuOpen } from "@mdi/js"; +import { fireEvent } from "@ha/common/dom/fire_event"; +import type { HomeAssistant } from "@ha/types"; + +import type { KNX } from "../types/knx"; + +@customElement("knx-dpt-dialog-selector") +class KnxDptDialogSelector extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public knx!: KNX; + + @property({ type: String }) public key!: string; + + @property({ attribute: false, type: Array }) public validDPTs?: string[]; + + @property() public value?: string; + + @property() public label?: string; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean, reflect: true }) public invalid = false; + + @property({ attribute: false }) public invalidMessage?: string; + + @property({ attribute: false }) public localizeValue: (value: string) => string = (key: string) => + key; + + @property({ type: String }) public translation_key?: string; + + render() { + return html` +
+ ${this.label ?? nothing} +
+ + + ${this.value + ? html`
+
${this.value}
+
+ ${this.hass.localize( + `component.knx.config_panel.dpt.options.${this.value.replace(".", "_")}`, + ) ?? this.knx.dptMetadata[this.value]?.name} +
+
${this.knx.dptMetadata[this.value]?.unit ?? ""}
+
+ ` + : html`
+ ${this.hass.localize("component.knx.config_panel.dpt.selector.no_selection") ?? + "No selection"} +
`} +
+ ${this.invalidMessage + ? html`

${this.invalidMessage}

` + : nothing} +
+ `; + } + + private _clearSelection(): void { + if (!this.value) return; + this.value = undefined; + fireEvent(this, "value-changed", { value: this.value }); + } + + private _openDialog() { + fireEvent(this, "show-dialog", { + dialogTag: "knx-dpt-select-dialog", + dialogImport: () => import("../dialogs/knx-dpt-select-dialog"), + dialogParams: (() => { + const filtered = (() => { + if (this.validDPTs && this.validDPTs.length) { + const set = new Set(this.validDPTs); + return Object.fromEntries( + Object.entries(this.knx.dptMetadata).filter(([k]) => set.has(k)), + ); + } + // Fallback: no explicit validDPTs provided — pass whole metadata mapping + return { ...this.knx.dptMetadata }; + })(); + + return { + title: this.hass.localize("component.knx.config_panel.dpt.selector.label"), + dpts: filtered, + initialSelection: this.value, + onClose: (dpt: string | undefined) => { + if (!dpt) return; + if (dpt === this.value) return; + this.value = dpt; + fireEvent(this, "value-changed", { value: this.value }); + }, + }; + })(), + }); + } + + static styles = [ + css` + :host([invalid]) div { + color: var(--error-color); + } + + p { + pointer-events: none; + color: var(--primary-text-color); + margin: 0px; + } + + .invalid-message { + font-size: 0.75rem; + color: var(--error-color); + padding-left: 16px; + } + + .knx-dpt-selector { + display: flex; + align-items: center; + gap: 8px; + } + + .knx-dpt-selector .selected { + display: grid; + /* first column adapts to content, middle column gets remaining space (shrinkable) + last column adapts to content as well (auto) — only the middle column truncates */ + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 8px; + flex: 1 1 auto; + min-width: 160px; + } + + .menu-button { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + } + + .clear-button { + margin-left: 8px; + } + + .dpt-number { + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", "Courier New", monospace; + color: var(--secondary-text-color); + white-space: nowrap; + } + + .dpt-name { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + /* allow the grid to shrink this column correctly */ + min-width: 0; + } + + .dpt-unit { + text-align: right; + color: var(--secondary-text-color); + white-space: nowrap; + padding-left: 6px; + } + `, + ]; +} + +declare global { + interface HTMLElementTagNameMap { + "knx-dpt-dialog-selector": KnxDptDialogSelector; + } +} diff --git a/src/components/knx-dpt-selector.ts b/src/components/knx-dpt-option-selector.ts similarity index 95% rename from src/components/knx-dpt-selector.ts rename to src/components/knx-dpt-option-selector.ts index 563fa498..95cd1308 100644 --- a/src/components/knx-dpt-selector.ts +++ b/src/components/knx-dpt-option-selector.ts @@ -7,8 +7,8 @@ import { fireEvent } from "@ha/common/dom/fire_event"; import type { DPTOption } from "../types/schema"; -@customElement("knx-dpt-selector") -class KnxDptSelector extends LitElement { +@customElement("knx-dpt-option-selector") +class KnxDptOptionSelector extends LitElement { @property({ type: Array }) public options!: DPTOption[]; @property() public value?: string; @@ -109,6 +109,6 @@ class KnxDptSelector extends LitElement { declare global { interface HTMLElementTagNameMap { - "knx-dpt-selector": KnxDptSelector; + "knx-dpt-option-selector": KnxDptOptionSelector; } } diff --git a/src/components/knx-group-address-selector.ts b/src/components/knx-group-address-selector.ts index 116c0a00..4915cddd 100644 --- a/src/components/knx-group-address-selector.ts +++ b/src/components/knx-group-address-selector.ts @@ -12,15 +12,15 @@ 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 "./knx-dpt-option-selector"; +import "./knx-dpt-dialog-selector"; import type { DragDropContext } from "../utils/drag-drop-context"; import { dragDropContext } from "../utils/drag-drop-context"; -import { isValidDPT } from "../utils/dpt"; +import { isValidDPT, dptToString, stringToDpt } from "../utils/dpt"; import { getValidationError } from "../utils/validation"; -import { dptToString } from "../utils/format"; import type { ErrorDescription, GASchema } from "../types/entity_data"; import type { KNX } from "../types/knx"; -import type { GASelectorOptions, DPTOption } from "../types/schema"; +import type { GASelectorOptions } from "../types/schema"; import type { DPT, GroupAddress } from "../types/websocket"; const getAddressOptions = ( @@ -84,8 +84,15 @@ export class GroupAddressSelector extends LitElement { : []; } - getDptOptionByValue(value: string | undefined): DPTOption | undefined { - return value ? this.options.dptSelect?.find((dpt) => dpt.value === value) : undefined; + getDptByValue(value: string | undefined): DPT | undefined { + if (!value) return undefined; + if (this.options.dptSelect) { + return this.options.dptSelect?.find((dpt) => dpt.value === value)?.dpt; + } + if (this.options.dptClasses) { + return stringToDpt(value) ?? undefined; + } + return undefined; } setFilteredGroupAddresses = memoize((dpt: DPT | undefined) => { @@ -100,11 +107,25 @@ export class GroupAddressSelector extends LitElement { return !(changedProps.size === 1 && changedProps.has("hass")); } + private _getDPTsFromClasses = memoize((dptClasses?: string[]): DPT[] => { + if (!dptClasses?.length || !this.knx.dptMetadata) return []; + const classes = new Set(dptClasses); + return Object.values(this.knx.dptMetadata) + .filter((meta) => classes.has(meta.dpt_class)) + .map((meta) => ({ main: meta.main, sub: meta.sub })); + }); + + private _getDptStringsFromClasses = memoize((dptClasses?: string[]): string[] => + this._getDPTsFromClasses(dptClasses).map(dptToString), + ); + protected willUpdate(changedProps: PropertyValues) { if (changedProps.has("options")) { // initialize + const acceptedDPTs = + this.options.validDPTs ?? this._getDPTsFromClasses(this.options.dptClasses); this.validGroupAddresses = this.getValidGroupAddresses( - this.options.validDPTs ?? this.options.dptSelect?.map((dptOption) => dptOption.dpt) ?? [], + acceptedDPTs ?? this.options.dptSelect?.map((dptOption) => dptOption.dpt) ?? [], ); this.filteredGroupAddresses = this.validGroupAddresses; this.addressOptions = getAddressOptions(this.filteredGroupAddresses); @@ -112,7 +133,7 @@ export class GroupAddressSelector extends LitElement { if (changedProps.has("config")) { this._selectedDPTValue = this.config.dpt ?? this._selectedDPTValue; - const selectedDPT = this.getDptOptionByValue(this._selectedDPTValue)?.dpt; + const selectedDPT = this.getDptByValue(this._selectedDPTValue); this.setFilteredGroupAddresses(selectedDPT); if (selectedDPT && this.knx.projectData) { @@ -254,16 +275,17 @@ export class GroupAddressSelector extends LitElement { ${this.options.validDPTs.map((dpt) => dptToString(dpt)).join(", ")}

` : nothing} - ${this.options.dptSelect ? this._renderDptSelector() : nothing} + ${this.options.dptSelect ? this._renderDptOptionSelector() : nothing} + ${this.options.dptClasses ? this._renderDptDialogSelector() : nothing} `; } - private _renderDptSelector() { + private _renderDptOptionSelector() { const invalid = getValidationError(this.validationErrors, "dpt"); - return html` - `; + `; + } + + private _renderDptDialogSelector() { + const invalid = getValidationError(this.validationErrors, "dpt"); + return html` + `; } private _updateConfig(ev: CustomEvent) { @@ -292,7 +333,7 @@ export class GroupAddressSelector extends LitElement { private _updateDptSelector(targetKey: string, newConfig: GASchema, hasGroupAddresses: boolean) { // updates newConfig in place - if (!this.options.dptSelect) return; + if (!this.options.dptSelect && !this.options.dptClasses) return; if (targetKey === "dpt") { this._selectedDPTValue = newConfig.dpt; @@ -314,13 +355,19 @@ export class GroupAddressSelector extends LitElement { 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, - ); - newConfig.dpt = exactDptMatch - ? exactDptMatch.value - : // fallback to first valid DPT if allowed in options; otherwise undefined - this.options.dptSelect.find((dptOption) => isValidDPT(newDpt, [dptOption.dpt]))?.value; + if (this.options.dptSelect) { + const exactDptMatch = this.options.dptSelect.find( + (dptOption) => dptOption.dpt.main === newDpt.main && dptOption.dpt.sub === newDpt.sub, + ); + newConfig.dpt = exactDptMatch + ? exactDptMatch.value + : // fallback to first valid DPT if allowed in options; otherwise undefined + this.options.dptSelect.find((dptOption) => isValidDPT(newDpt, [dptOption.dpt]))?.value; + } else if (this.options.dptClasses) { + const stringDpt = dptToString(newDpt); + const validDPTsFromClasses = this._getDptStringsFromClasses(this.options.dptClasses); + newConfig.dpt = validDPTsFromClasses.includes(stringDpt) ? stringDpt : undefined; + } } private _getAddedGroupAddress(targetKey: string, newConfig: GASchema): string | undefined { @@ -402,7 +449,7 @@ export class GroupAddressSelector extends LitElement { } else { newConfig[target.key] = ga; } - this._updateDptSelector(target.key, newConfig); + this._updateDptSelector(target.key, newConfig, true); fireEvent(this, "value-changed", { value: newConfig }); // reset invalid state of textfield if set before drag setTimeout(() => target.comboBox._inputElement.blur()); diff --git a/src/components/knx-project-device-tree.ts b/src/components/knx-project-device-tree.ts index d2878b1c..d3e8083b 100644 --- a/src/components/knx-project-device-tree.ts +++ b/src/components/knx-project-device-tree.ts @@ -17,8 +17,7 @@ import type { } 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"; +import { filterValidComObjects, dptToString } from "../utils/dpt"; const logger = new KNXLogger("knx-project-device-tree"); diff --git a/src/dialogs/knx-dpt-select-dialog.ts b/src/dialogs/knx-dpt-select-dialog.ts new file mode 100644 index 00000000..dcef626c --- /dev/null +++ b/src/dialogs/knx-dpt-select-dialog.ts @@ -0,0 +1,327 @@ +import memoize from "memoize-one"; +import { LitElement, html, css, nothing } from "lit"; +import { customElement, property, state } from "lit/decorators"; + +import "@ha/components/ha-wa-dialog"; +import "@ha/components/ha-button"; +import "@ha/components/ha-dialog-footer"; +import "@ha/components/search-input"; +import "@ha/components/ha-md-list"; +import "@ha/components/ha-md-list-item"; +import "@ha/components/ha-section-title"; + +import { fireEvent } from "@ha/common/dom/fire_event"; +import { haStyleDialog } from "@ha/resources/styles"; +import type { HomeAssistant } from "@ha/types"; +import type { HassDialog } from "@ha/dialogs/make-dialog-manager"; + +import { stringToDpt, compareDpt } from "../utils/dpt"; +import type { DPTMetadata } from "../types/websocket"; + +export interface KnxDptSelectDialogParams { + dpts: Record; + title?: string; + width?: "small" | "medium" | "large" | "full"; + + /** Optional initial selection to preselect in the dialog */ + initialSelection?: string; + + /** Optional callback invoked when the dialog closes. Receives the selected DPT or undefined. */ + onClose?: (dpt: string | undefined) => void; +} + +@customElement("knx-dpt-select-dialog") +export class KnxDptSelectDialog extends LitElement implements HassDialog { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _open = false; + + @state() private _params?: KnxDptSelectDialogParams; + + @state() private dpts: Record = {}; + + /** Currently selected DPT */ + @state() private _selected?: string; + + /** Filter string for the DPT list */ + @state() private _filter = ""; + + public async showDialog(params: KnxDptSelectDialogParams): Promise { + this._params = params; + this.dpts = params.dpts ?? {}; + this._selected = params.initialSelection ?? this._selected; + this._open = true; + } + + public closeDialog(_historyState?: any): boolean { + this._dialogClosed(); + return true; + } + + private _cancel(): void { + this._selected = undefined; + // Inform caller via callback that dialog was closed without a selection + if (this._params?.onClose) { + this._params.onClose(undefined); + } + this._dialogClosed(); + } + + private _confirm(): void { + // If a callback was provided by the caller, call it with the selected value. + if (this._params?.onClose) { + this._params.onClose(this._selected); + } + this._dialogClosed(); + } + + private _onDoubleClick(ev: Event): void { + const target = ev.currentTarget as HTMLElement; + const value = target.getAttribute("value") ?? (target.dataset && target.dataset.value); + this._selected = value ?? undefined; + + if (this._selected) { + this._confirm(); + } + } + + private _onSelect(ev: Event): void { + const target = ev.currentTarget as HTMLElement; + const value = target.getAttribute("value") ?? (target.dataset && target.dataset.value); + this._selected = value ?? undefined; + } + + private _onFilterChanged(ev: CustomEvent<{ value: string }>): void { + this._filter = ev.detail?.value ?? ""; + } + + private _groupDpts = memoize( + (filter: string, dpts: Record): { title: string; items: string[] }[] => { + const map = new Map(); + + const filterLower = filter.trim().toLowerCase(); + + for (const dpt of Object.keys(dpts)) { + const info = this._getDptInfo(dpt); + // If a filter is provided, match against number, label or unit + if (filterLower) { + const matchesNumber = dpt.toLowerCase().includes(filterLower); + const matchesLabel = info.label?.toLowerCase().includes(filterLower); + const matchesUnit = info.unit ? info.unit.toLowerCase().includes(filterLower) : false; + if (!matchesNumber && !matchesLabel && !matchesUnit) { + continue; + } + } + + const major = String(dpt).split(".", 1)[0] || dpt; + const key = `${major}`; + if (!map.has(key)) { + map.set(key, []); + } + map.get(key)!.push(dpt); + } + + // Sort groups by numeric major value if possible; within each group sort by minor number + const groups = Array.from(map.entries()) + .sort((a, b) => { + const na = Number(a[0]); + const nb = Number(b[0]); + if (!Number.isNaN(na) && !Number.isNaN(nb)) { + return na - nb; + } + return a[0].localeCompare(b[0]); + }) + .map(([key, items]) => ({ + title: `${key}.*`, + items: items.sort((x, y) => { + const parsedX = stringToDpt(x); + const parsedY = stringToDpt(y); + if (parsedX && parsedY) { + return compareDpt(parsedX, parsedY); + } + if (parsedX) { + return -1; + } + if (parsedY) { + return 1; + } + return x.localeCompare(y); + }), + })); + + return groups; + }, + ); + + private _getDptInfo(dpt: string): { label: string; unit: string } { + const meta = this.dpts[dpt]; + return { + label: + this.hass.localize(`component.knx.config_panel.dpt.options.${dpt.replace(".", "_")}`) ?? + meta?.name ?? + this.hass.localize("state.default.unknown"), + unit: meta?.unit ?? "", + }; + } + + private _itemKeydown(ev: KeyboardEvent): void { + if (ev.key === "Enter") { + ev.preventDefault(); + const target = ev.currentTarget as HTMLElement; + const value = target.getAttribute("value") ?? (target.dataset && target.dataset.value); + this._selected = value ?? undefined; + this._confirm(); + } + } + + private _dialogClosed(): void { + this._open = false; + this._params = undefined; + this._filter = ""; + this._selected = undefined; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + protected render() { + if (!this._params || !this.hass) { + return nothing; + } + + const width = this._params.width ?? "medium"; + return html` +
+ + + ${Object.keys(this.dpts).length + ? html`
+ ${this._groupDpts(this._filter, this.dpts).map( + (group) => html` + ${group.title + ? html`${group.title}` + : nothing} + + ${group.items.map((dpt) => { + const info = this._getDptInfo(dpt); + const isSelected = this._selected === dpt; + return html` +
+
${dpt}
+
${info.label}
+
${info.unit}
+
+
`; + })} +
+ `, + )} +
` + : html`
No options
`} +
+ + + + ${this.hass.localize("ui.common.cancel") ?? "Cancel"} + + + ${this.hass.localize("ui.common.ok") ?? "OK"} + + +
`; + } + + static get styles() { + return [ + haStyleDialog, + css` + @media all and (min-width: 600px) { + ha-wa-dialog { + --mdc-dialog-min-width: 360px; + } + } + + .dialog-body { + display: flex; + flex-direction: column; + gap: var(--ha-space-2, 8px); + height: 100%; + min-height: 0; + } + + search-input { + display: block; + width: 100%; + } + + .dpt-list-container { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + border: 1px solid var(--divider-color); + border-radius: 4px; + } + + .dpt-row { + display: grid; + grid-template-columns: 8ch minmax(0, 1fr) auto; + align-items: center; + gap: var(--ha-space-2, 8px); + padding: 6px 8px; + border-radius: 4px; + } + + .dpt-row.selected { + background-color: rgba(var(--rgb-primary-color), 0.08); + outline: 2px solid rgba(var(--rgb-accent-color), 0.12); + } + + .dpt-number { + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, "Roboto Mono", "Courier New", monospace; + width: 100%; + color: var(--secondary-text-color); + white-space: nowrap; + } + + .dpt-name { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } + + .dpt-unit { + text-align: right; + color: var(--secondary-text-color); + white-space: nowrap; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "knx-dpt-select-dialog": KnxDptSelectDialog; + } +} diff --git a/src/dialogs/show-knx-dpt-select-dialog.ts b/src/dialogs/show-knx-dpt-select-dialog.ts new file mode 100644 index 00000000..e86fa485 --- /dev/null +++ b/src/dialogs/show-knx-dpt-select-dialog.ts @@ -0,0 +1,15 @@ +import { fireEvent } from "@ha/common/dom/fire_event"; +import type { KnxDptSelectDialogParams } from "./knx-dpt-select-dialog"; + +export const loadKnxDptSelectDialog = () => import("./knx-dpt-select-dialog"); + +export const showKnxDptSelectDialog = ( + element: HTMLElement, + dialogParams: KnxDptSelectDialogParams, +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "knx-dpt-select-dialog", + dialogImport: loadKnxDptSelectDialog, + dialogParams, + }); +}; diff --git a/src/knx.ts b/src/knx.ts index 431bc099..34b452dd 100644 --- a/src/knx.ts +++ b/src/knx.ts @@ -26,6 +26,7 @@ export class KnxElement extends ProvideHassLitMixin(LitElement) { localize: (string, replace) => localize(this.hass, string, replace), log: new KNXLogger(), connectionInfo: knxBase.connection_info, + dptMetadata: knxBase.dpt_metadata, projectInfo: knxBase.project_info, // can be used to check if project is available supportedPlatforms: knxBase.supported_platforms, projectData: null, diff --git a/src/localize/languages/de.json b/src/localize/languages/de.json index 3498ac50..641c0e48 100644 --- a/src/localize/languages/de.json +++ b/src/localize/languages/de.json @@ -55,6 +55,7 @@ "project_view_table_updated": "Aktualisiert", "project_view_menu_view_telegrams": "Telegramme anzeigen", "project_view_menu_create_binary_sensor": "Binärsensor erstellen", + "project_view_menu_create_sensor": "Sensor erstellen", "entities_view_monitor_telegrams": "Telegramme überwachen", "Incoming": "Eingehend", "Outgoing": "Ausgehend", diff --git a/src/localize/languages/en.json b/src/localize/languages/en.json index ca190d33..23da2daa 100644 --- a/src/localize/languages/en.json +++ b/src/localize/languages/en.json @@ -56,6 +56,7 @@ "project_view_add_switch": "Add switch", "project_view_menu_view_telegrams": "View telegrams", "project_view_menu_create_binary_sensor": "Create binary sensor", + "project_view_menu_create_sensor": "Create sensor", "entities_view_monitor_telegrams": "Monitor telegrams", "Incoming": "Incoming", "Outgoing": "Outgoing", diff --git a/src/main.ts b/src/main.ts index 436c24bc..2c0043af 100644 --- a/src/main.ts +++ b/src/main.ts @@ -42,6 +42,7 @@ class KnxFrontend extends KnxElement { await this._initKnx(); } await this.hass.loadBackendTranslation("config_panel", "knx", false); + await this.hass.loadBackendTranslation("selector", "knx", false); await this.hass.loadBackendTranslation("title", this.knx.supportedPlatforms, false); await this.hass.loadBackendTranslation("selector", this.knx.supportedPlatforms, false); await this.hass.loadFragmentTranslation("config"); diff --git a/src/types/knx.ts b/src/types/knx.ts index 30c889ca..dce5b6c1 100644 --- a/src/types/knx.ts +++ b/src/types/knx.ts @@ -1,7 +1,7 @@ import type { ConfigEntry } from "@ha/data/config_entries"; import type { SupportedPlatform } from "./entity_data"; import type { SelectorSchema } from "./schema"; -import type { KNXInfoData, KNXProjectInfo, KNXProject } from "./websocket"; +import type { DPTMetadata, KNXInfoData, KNXProjectInfo, KNXProject } from "./websocket"; export interface KNX { language: string; @@ -9,6 +9,7 @@ export interface KNX { localize(string: string, replace?: Record): string; log: any; connectionInfo: KNXInfoData; + dptMetadata: Record; projectInfo: KNXProjectInfo | null; supportedPlatforms: SupportedPlatform[]; projectData: KNXProject | null; diff --git a/src/types/schema.ts b/src/types/schema.ts index 9eaebd9f..cde00c22 100644 --- a/src/types/schema.ts +++ b/src/types/schema.ts @@ -61,8 +61,9 @@ export interface GASelectorOptions { write?: { required: boolean }; state?: { required: boolean }; passive?: boolean; - validDPTs?: DPT[]; // one of validDPTs or dptSelect shall be set + validDPTs?: DPT[]; // one of validDPTs, dptSelect or dptClasses shall be set dptSelect?: DPTOption[]; + dptClasses?: string[]; } export interface DPTOption { diff --git a/src/types/websocket.ts b/src/types/websocket.ts index b01b47c5..30319c3f 100644 --- a/src/types/websocket.ts +++ b/src/types/websocket.ts @@ -2,6 +2,7 @@ import type { SupportedPlatform } from "./entity_data"; export interface KNXBaseData { connection_info: KNXInfoData; + dpt_metadata: Record; project_info: KNXProjectInfo | null; supported_platforms: SupportedPlatform[]; } @@ -12,6 +13,16 @@ export interface KNXInfoData { current_address: string; } +export interface DPTMetadata { + dpt_class: "numeric" | "enum" | "complex" | "string"; + main: number; + sub: number | null; + name: string | null; // TODO: remove in favour of translation? + unit: string | null; + sensor_device_class: string | null; + sensor_state_class: string | null; +} + export interface KNXProjectInfo { name: string; last_modified: string; diff --git a/src/utils/dpt.test.ts b/src/utils/dpt.test.ts new file mode 100644 index 00000000..24db579d --- /dev/null +++ b/src/utils/dpt.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it } from "vitest"; +import { compareDpt, dptToString, stringToDpt, dptInClasses } from "./dpt"; +import type { DPT, DPTMetadata } from "../types/websocket"; + +describe("dptToString", () => { + it("should return empty string for null DPT", () => { + expect(dptToString(null)).toBe(""); + }); + + it("should format main DPT only", () => { + const dpt: DPT = { main: 1, sub: null }; + expect(dptToString(dpt)).toBe("1"); + }); + + it("should format main and sub DPT with padding", () => { + const dpt: DPT = { main: 1, sub: 1 }; + expect(dptToString(dpt)).toBe("1.001"); + }); + + it("should handle large DPT numbers", () => { + const dpt: DPT = { main: 20, sub: 102 }; + expect(dptToString(dpt)).toBe("20.102"); + }); +}); + +describe("stringToDpt", () => { + it("should parse main segment only", () => { + expect(stringToDpt("5")).toEqual({ main: 5, sub: null }); + }); + + it("should parse main and sub segments with padding", () => { + expect(stringToDpt("9.007")).toEqual({ main: 9, sub: 7 }); + }); + + it("should handle larger sub numbers", () => { + expect(stringToDpt("20.102")).toEqual({ main: 20, sub: 102 }); + }); + + it("should ignore surrounding whitespace", () => { + expect(stringToDpt(" 15.001 \n")).toEqual({ main: 15, sub: 1 }); + }); + + it("should return null for empty string", () => { + expect(stringToDpt("")).toBeNull(); + }); + + it("should return null for invalid main segment", () => { + expect(stringToDpt("abc")).toBeNull(); + }); + + it("should return null for invalid sub segment", () => { + expect(stringToDpt("1.xyz")).toBeNull(); + }); + + it("should return null for trailing separator", () => { + expect(stringToDpt("3.")).toBeNull(); + }); + + it("should return null when more than one separator is present", () => { + expect(stringToDpt("1.2.3")).toBeNull(); + }); +}); + +describe("compareDpt", () => { + it("should sort by main value first", () => { + const dpts = [ + { main: 6, sub: null }, + { main: 5, sub: 3 }, + { main: 4, sub: null }, + ]; + const sorted = [...dpts].sort(compareDpt); + expect(sorted.map((dpt) => dpt.main)).toEqual([4, 5, 6]); + }); + + it("should place null sub before numeric sub", () => { + const a = { main: 5, sub: null }; + const b = { main: 5, sub: 1 }; + expect(compareDpt(a, b)).toBeLessThan(0); + expect(compareDpt(b, a)).toBeGreaterThan(0); + }); + + it("should compare numeric sub values when both present", () => { + const a = { main: 5, sub: 1 }; + const b = { main: 5, sub: 10 }; + expect(compareDpt(a, b)).toBeLessThan(0); + expect(compareDpt(b, a)).toBeGreaterThan(0); + }); + + it("should consider equal DPTs as equivalent", () => { + const a = { main: 7, sub: 1 }; + const b = { main: 7, sub: 1 }; + expect(compareDpt(a, b)).toBe(0); + }); +}); + +describe("dptInClasses", () => { + const mockMetadata: Record = { + "1.001": { main: 1, sub: 1, name: "Switch", unit: "", dpt_class: "enum" }, + "5": { main: 5, sub: null, name: "Unsigned", unit: "", dpt_class: "numeric" }, + "5.001": { main: 5, sub: 1, name: "Percentage", unit: "%", dpt_class: "numeric" }, + "9.001": { main: 9, sub: 1, name: "Temperature", unit: "°C", dpt_class: "numeric" }, + "16.001": { main: 16, sub: 1, name: "String", unit: "", dpt_class: "string" }, + }; + + it("should return true when DPT is in specified class", () => { + const dpt: DPT = { main: 1, sub: 1 }; + expect(dptInClasses(dpt, ["enum"], mockMetadata)).toBe(true); + }); + + it("should return true when DPT is in one of multiple classes", () => { + const dpt: DPT = { main: 9, sub: 1 }; + expect(dptInClasses(dpt, ["numeric", "string"], mockMetadata)).toBe(true); + }); + + it("should return false when DPT is not in any specified class", () => { + const dpt: DPT = { main: 1, sub: 1 }; + expect(dptInClasses(dpt, ["numeric", "string"], mockMetadata)).toBe(false); + }); + + it("should return false when DPT is not in any specified class", () => { + const dpt: DPT = { main: 16, sub: 1 }; + expect(dptInClasses(dpt, ["numeric"], mockMetadata)).toBe(false); + }); + + it("should return false when DPT is not in metadata", () => { + const dpt: DPT = { main: 99, sub: 99 }; + expect(dptInClasses(dpt, ["numeric"], mockMetadata)).toBe(false); + }); + + it("should return false for main-only DPT when only full DPT exists in metadata", () => { + const dpt: DPT = { main: 9, sub: null }; + expect(dptInClasses(dpt, ["numeric"], mockMetadata)).toBe(false); + }); + + it("should return true for main-only DPT when it exists in metadata", () => { + const dpt: DPT = { main: 5, sub: null }; + expect(dptInClasses(dpt, ["numeric"], mockMetadata)).toBe(true); + }); + + it("should handle empty class list", () => { + const dpt: DPT = { main: 1, sub: 1 }; + expect(dptInClasses(dpt, [], mockMetadata)).toBe(false); + }); +}); diff --git a/src/utils/dpt.ts b/src/utils/dpt.ts index 6bb3418a..1f8b2507 100644 --- a/src/utils/dpt.ts +++ b/src/utils/dpt.ts @@ -1,5 +1,11 @@ import memoize from "memoize-one"; -import type { DPT, KNXProject, CommunicationObject, GroupAddress } from "../types/websocket"; +import type { + DPT, + KNXProject, + CommunicationObject, + GroupAddress, + DPTMetadata, +} from "../types/websocket"; import type { SelectorSchema, GroupSelectOption } from "../types/schema"; export const equalDPT = (dpt1: DPT, dpt2: DPT): boolean => @@ -50,7 +56,10 @@ export const filterDuplicateDPTs = (dpts: DPT[]): DPT[] => [] as DPT[], ); -function _validDPTsForSchema(schema: (SelectorSchema | GroupSelectOption)[]): DPT[] { +function _validDPTsForSchema( + schema: (SelectorSchema | GroupSelectOption)[], + dptMetadata: Record, +): DPT[] { const result: DPT[] = []; schema.forEach((item) => { if (item.type === "knx_group_address") { @@ -58,17 +67,71 @@ function _validDPTsForSchema(schema: (SelectorSchema | GroupSelectOption)[]): DP result.push(...item.options.validDPTs); } else if (item.options.dptSelect) { result.push(...item.options.dptSelect.map((dptOption) => dptOption.dpt)); + } else if (item.options.dptClasses) { + result.push( + ...Object.values(dptMetadata) + .filter((dptMeta) => item.options.dptClasses!.includes(dptMeta.dpt_class)) + .map((dptMeta) => ({ main: dptMeta.main, sub: dptMeta.sub })), + ); } return; } if ("schema" in item) { // Section or GroupSelect - result.push(..._validDPTsForSchema(item.schema)); + result.push(..._validDPTsForSchema(item.schema, dptMetadata)); } }); return result; } -export const validDPTsForSchema = memoize((schema: SelectorSchema[]): DPT[] => - filterDuplicateDPTs(_validDPTsForSchema(schema)), +export const validDPTsForSchema = memoize( + (schema: SelectorSchema[], dptMetadata: Record): DPT[] => + filterDuplicateDPTs(_validDPTsForSchema(schema, dptMetadata)), ); + +export const dptToString = (dpt: DPT | null): string => { + if (dpt == null) return ""; + return dpt.main + (dpt.sub != null ? "." + dpt.sub.toString().padStart(3, "0") : ""); +}; + +export const stringToDpt = (raw: string): DPT | null => { + if (!raw) return null; + const parts = raw.trim().split("."); + if (parts.length === 0 || parts.length > 2) { + return null; + } + const main = Number.parseInt(parts[0], 10); + if (Number.isNaN(main)) { + return null; + } + if (parts.length === 1) { + return { main, sub: null }; + } + const sub = Number.parseInt(parts[1], 10); + if (Number.isNaN(sub)) { + return null; + } + return { main, sub }; +}; + +export const compareDpt = (left: DPT, right: DPT): number => { + if (left.main !== right.main) { + return left.main - right.main; + } + const leftSub = left.sub ?? -1; + const rightSub = right.sub ?? -1; + return leftSub - rightSub; +}; + +export const dptInClasses = ( + dpt: DPT, + dptClasses: string[], + dptMetadata: Record, +): boolean => { + const key = dptToString(dpt); + const metadata = dptMetadata[key]; + if (!metadata) { + return false; + } + return dptClasses.includes(metadata.dpt_class); +}; diff --git a/src/utils/format.test.ts b/src/utils/format.test.ts index ef74cb86..4ae52b91 100644 --- a/src/utils/format.test.ts +++ b/src/utils/format.test.ts @@ -2,13 +2,12 @@ import { describe, it, expect } from "vitest"; import { formatTimeDelta, TelegramDictFormatter, - dptToString, formatTimeWithMilliseconds, formatDateTimeWithMilliseconds, formatIsoTimestampWithMicroseconds, extractMicrosecondsFromIso, } from "./format"; -import type { TelegramDict, DPT } from "../types/websocket"; +import type { TelegramDict } from "../types/websocket"; /** * Helper to create mock telegram data for testing @@ -473,24 +472,3 @@ describe("TelegramDictFormatter", () => { }); }); }); - -describe("dptToString", () => { - it("should return empty string for null DPT", () => { - expect(dptToString(null)).toBe(""); - }); - - it("should format main DPT only", () => { - const dpt: DPT = { main: 1, sub: null }; - expect(dptToString(dpt)).toBe("1"); - }); - - it("should format main and sub DPT with padding", () => { - const dpt: DPT = { main: 1, sub: 1 }; - expect(dptToString(dpt)).toBe("1.001"); - }); - - it("should handle large DPT numbers", () => { - const dpt: DPT = { main: 20, sub: 102 }; - expect(dptToString(dpt)).toBe("20.102"); - }); -}); diff --git a/src/utils/format.ts b/src/utils/format.ts index 4f2282f4..1a40374e 100644 --- a/src/utils/format.ts +++ b/src/utils/format.ts @@ -1,5 +1,5 @@ import { dump } from "js-yaml"; -import type { DPT, TelegramDict } from "../types/websocket"; +import type { TelegramDict } from "../types/websocket"; import type { TimePrecision } from "../features/group-monitor"; export const TelegramDictFormatter = { @@ -60,11 +60,6 @@ export const TelegramDictFormatter = { }, }; -export const dptToString = (dpt: DPT | null): string => { - if (dpt == null) return ""; - return dpt.main + (dpt.sub ? "." + dpt.sub.toString().padStart(3, "0") : ""); -}; - /** * Format a Date object to a time string with milliseconds. */ diff --git a/src/views/entities_create.ts b/src/views/entities_create.ts index ebe3156d..1d4bd230 100644 --- a/src/views/entities_create.ts +++ b/src/views/entities_create.ts @@ -323,7 +323,7 @@ export class KNXCreateEntity extends LitElement { ? html`
` : nothing} diff --git a/src/views/project_view.ts b/src/views/project_view.ts index e4809b53..af3d8010 100644 --- a/src/views/project_view.ts +++ b/src/views/project_view.ts @@ -25,6 +25,7 @@ import "../components/knx-project-tree-view"; import { compare } from "compare-versions"; import type { HomeAssistant, Route } from "@ha/types"; +import { dptInClasses } from "utils/dpt"; import type { KNX } from "../types/knx"; import type { GroupRangeSelectionChangedEvent } from "../components/knx-project-tree-view"; import { subscribeKnxTelegrams, getGroupTelegrams } from "../services/websocket.service"; @@ -185,18 +186,33 @@ export class KNXProjectView extends LitElement { }, }); - if (groupAddress.dpt?.main === 1) { - items.push({ - path: mdiPlus, - label: this.knx.localize("project_view_menu_create_binary_sensor"), - action: () => { - navigate( - "/knx/entities/create/binary_sensor?knx.ga_sensor.state=" + groupAddress.address, - ); - }, - }); + if (groupAddress.dpt) { + if (groupAddress.dpt.main === 1) { + items.push({ + path: mdiPlus, + label: this.knx.localize("project_view_menu_create_binary_sensor"), + action: () => { + navigate( + "/knx/entities/create/binary_sensor?knx.ga_sensor.state=" + groupAddress.address, + ); + }, + }); + } else if (dptInClasses(groupAddress.dpt, ["numeric", "string"], this.knx.dptMetadata)) { + items.push({ + path: mdiPlus, + label: this.knx.localize("project_view_menu_create_sensor") ?? "Create Sensor", + action: () => { + const dptString = groupAddress.dpt + ? `${groupAddress.dpt.main}${groupAddress.dpt.sub !== null ? "." + groupAddress.dpt.sub.toString().padStart(3, "0") : ""}` + : ""; + navigate( + `/knx/entities/create/sensor?knx.ga_sensor.state=${groupAddress.address}` + + `${dptString ? `&knx.ga_sensor.dpt=${dptString}` : ""}`, + ); + }, + }); + } } - return html` `;