Skip to content
2 changes: 2 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ export default tseslint.config(
sourceType: "module",

parserOptions: {
project: "./tsconfig.json",
tsconfigRootDir: _dirname,
ecmaFeatures: {
modules: true,
},
Expand Down
190 changes: 190 additions & 0 deletions src/components/knx-dpt-dialog-selector.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { LitElement, html, css, nothing } from "lit";
import { customElement, property } from "lit/decorators";

import "@ha/components/ha-formfield";
import "@ha/components/ha-radio";
Comment thread
farmio marked this conversation as resolved.
Outdated
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({ 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;

Comment thread
farmio marked this conversation as resolved.
render() {
return html`
<div>
${this.label ?? nothing}
<div class="knx-dpt-selector">
<ha-icon-button
class="menu-button"
.path=${mdiMenuOpen}
@click=${this._openDialog}
label="Select DPT"
Comment thread
farmio marked this conversation as resolved.
Outdated
></ha-icon-button>
Comment on lines +41 to +46

Copilot AI Dec 9, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing disabled attribute handling on the menu button. The component has a disabled property but it's not being applied to the ha-icon-button elements. Consider adding .disabled=${this.disabled} to the menu button and clear button to prevent interaction when the component is disabled.

Copilot uses AI. Check for mistakes.

${this.value
? html`<div class="selected">
<div class="dpt-number">${this.value}</div>
<div class="dpt-name">${this.knx.dptMetadata[this.value]?.name}</div>
<div class="dpt-unit">${this.knx.dptMetadata[this.value]?.unit ?? ""}</div>
</div>
<ha-icon-button
class="clear-button"
.path=${mdiClose}
.label=${this.hass.localize ? this.hass.localize("ui.common.clear") : "Clear"}
@click=${this._clearSelection}
Comment on lines +41 to +62

Copilot AI Dec 11, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The disabled property is declared but never used in the component. The menu button and clear button should respect this property to prevent user interaction when the selector is disabled.

Copilot uses AI. Check for mistakes.
></ha-icon-button>`
: html`<div>Select DPT</div>`}
Comment thread
farmio marked this conversation as resolved.
Outdated
</div>
${this.invalidMessage
? html`<p class="invalid-message">${this.invalidMessage}</p>`
: nothing}
</div>
`;
}

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 caller provided explicit valid DPT keys, use them to filter metadata.
if (!this.knx?.dptMetadata) return {} as Record<string, any>;
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 ? this.hass.localize("ui.common.select") : "Select DPT",
// `dpts` must be a Record<string, DPTMetadata> — pass filtered mapping (may be empty)
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;
}

/* Removed unused selectors: .formfield, label, .secondary, .menu-label */

.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);
}

.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-number {
white-space: nowrap;
}
Comment thread
farmio marked this conversation as resolved.
Outdated

.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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -109,6 +109,6 @@ class KnxDptSelector extends LitElement {

declare global {
interface HTMLElementTagNameMap {
"knx-dpt-selector": KnxDptSelector;
"knx-dpt-option-selector": KnxDptOptionSelector;
}
}
93 changes: 73 additions & 20 deletions src/components/knx-group-address-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@ 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 { 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 = (
Expand Down Expand Up @@ -84,8 +85,16 @@ 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) {
const parts = value.split(".");
return { main: Number(parts[0]), sub: parts[1] ? Number(parts[1]) : null };
}
return undefined;
}

setFilteredGroupAddresses = memoize((dpt: DPT | undefined) => {
Expand All @@ -100,19 +109,37 @@ export class GroupAddressSelector extends LitElement {
return !(changedProps.size === 1 && changedProps.has("hass"));
}

private _getDPTsFromClasses(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(dptClasses?: string[]): string[] {
if (!dptClasses?.length) return [];
const classes = new Set(dptClasses);
return Object.entries(this.knx.dptMetadata)
.filter(([, meta]) => classes.has(meta.dpt_class))
.map(([key]) => key);
Comment thread
farmio marked this conversation as resolved.
Outdated
}
Comment thread
farmio marked this conversation as resolved.
Outdated

protected willUpdate(changedProps: PropertyValues<this>) {
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);
}

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) {
Expand Down Expand Up @@ -254,16 +281,36 @@ export class GroupAddressSelector extends LitElement {
${this.options.validDPTs.map((dpt) => dptToString(dpt)).join(", ")}
</p>`
: 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`<knx-dpt-option-selector
.key=${"dpt"}
.label=${this._baseTranslation("dpt")}
.options=${this.options.dptSelect!}
.value=${this._selectedDPTValue}
.disabled=${this.dptSelectorDisabled}
.invalid=${!!invalid}
.invalidMessage=${invalid?.error_message}
.localizeValue=${this.localizeFunction}
.translation_key=${this.key}
@value-changed=${this._updateConfig}
>
</knx-dpt-option-selector>`;
}

private _renderDptDialogSelector() {
const invalid = getValidationError(this.validationErrors, "dpt");
return html`<knx-dpt-selector
return html`<knx-dpt-dialog-selector
.key=${"dpt"}
.label=${this._baseTranslation("dpt")}
.options=${this.options.dptSelect}
.hass=${this.hass}
.knx=${this.knx}
.validDPTs=${this._getDptStringsFromClasses(this.options.dptClasses)}
.value=${this._selectedDPTValue}
.disabled=${this.dptSelectorDisabled}
.invalid=${!!invalid}
Expand All @@ -272,7 +319,7 @@ export class GroupAddressSelector extends LitElement {
.translation_key=${this.key}
@value-changed=${this._updateConfig}
>
</knx-dpt-selector>`;
</knx-dpt-dialog-selector>`;
}

private _updateConfig(ev: CustomEvent) {
Expand All @@ -292,7 +339,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;
Expand All @@ -314,13 +361,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 {
Expand Down Expand Up @@ -402,7 +455,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());
Expand Down
Loading