Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
189 changes: 189 additions & 0 deletions src/components/knx-dpt-dialog-selector.ts
Original file line number Diff line number Diff line change
@@ -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;

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=${this.hass.localize("component.knx.config_panel.dpt.selector.label")}
></ha-icon-button>
Comment on lines +41 to +46
Copy link

Copilot AI Dec 9, 2025

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.hass.localize(
`component.knx.config_panel.dpt.options.${this.value.replace(".", "_")}`,
) ?? 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("ui.common.clear") ?? "Clear"}
@click=${this._clearSelection}
Comment on lines +41 to +62
Copy link

Copilot AI Dec 11, 2025

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>
${this.hass.localize("component.knx.config_panel.dpt.selector.no_selection") ??
"No selection"}
</div>`}
</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 (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;
}
}
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;
}
}
91 changes: 69 additions & 22 deletions src/components/knx-group-address-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
Expand Down Expand Up @@ -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) => {
Expand All @@ -100,19 +107,33 @@ 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<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 +275,17 @@ 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-selector
return html`<knx-dpt-option-selector
.key=${"dpt"}
.label=${this._baseTranslation("dpt")}
.options=${this.options.dptSelect}
.options=${this.options.dptSelect!}
.value=${this._selectedDPTValue}
.disabled=${this.dptSelectorDisabled}
.invalid=${!!invalid}
Expand All @@ -272,7 +294,26 @@ export class GroupAddressSelector extends LitElement {
.translation_key=${this.key}
@value-changed=${this._updateConfig}
>
</knx-dpt-selector>`;
</knx-dpt-option-selector>`;
}

private _renderDptDialogSelector() {
const invalid = getValidationError(this.validationErrors, "dpt");
return html`<knx-dpt-dialog-selector
.key=${"dpt"}
.label=${this._baseTranslation("dpt")}
.hass=${this.hass}
.knx=${this.knx}
.validDPTs=${this._getDptStringsFromClasses(this.options.dptClasses)}
.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-dialog-selector>`;
}

private _updateConfig(ev: CustomEvent) {
Expand All @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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());
Expand Down
3 changes: 1 addition & 2 deletions src/components/knx-project-device-tree.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand Down
Loading