/** * @license * Copyright 2025-2026 Open Home Foundation * SPDX-License-Identifier: Apache-2.0 */ import "@material/web/button/text-button"; import "@material/web/dialog/dialog"; import "@material/web/select/outlined-select"; import "@material/web/select/select-option"; import "@material/web/textfield/outlined-text-field"; import { consume } from "@lit/context"; import type { MdDialog } from "@material/web/dialog/dialog.js"; import { MatterClient, MatterNode } from "@matter-server/ws-client"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state } from "lit/decorators.js"; import { clientContext } from "../../../client/client-context.js"; import { clusters } from "../../../client/models/descriptions.js"; import { nodeIdKey } from "../../../util/access-control.js"; import { handleAsync } from "../../../util/async-handler.js"; import { bindableClusters, targetAclCapacityForBinding } from "../../../util/binding.js"; import { getEndpointDeviceTypes } from "../../../util/endpoints.js"; import { getDeviceName } from "../../../util/node-name.js"; import { preventDefault } from "../../../util/prevent_default.js"; import { showAlertDialog } from "../../dialog-box/show-dialog-box.js"; import { addBinding } from "./binding-actions.js"; const ALL_CLUSTERS = "all"; const CUSTOM_CLUSTER = "custom"; @customElement("node-binding-dialog") export class NodeBindingDialog extends LitElement { @consume({ context: clientContext, subscribe: true }) @property({ attribute: false }) public client!: MatterClient; @property() public node?: MatterNode; @property({ attribute: false }) endpoint!: number; @state() private _nodeIdInput = ""; @state() private _endpointInput = ""; @state() private _clusterSelection = ALL_CLUSTERS; @state() private _customClusterInput = ""; @state() private _busy = false; private _knownNodes(): MatterNode[] { // Includes the source node itself — a self-binding (e.g. switch → light on the same node) // is valid and needs no ACL. return Object.values(this.client.nodes).sort((a, b) => { const x = BigInt(a.node_id); const y = BigInt(b.node_id); return x < y ? -1 : x > y ? 1 : 0; }); } private _resolveTarget(): MatterNode | undefined { const raw = this._nodeIdInput.trim(); if (!/^\d+$/.test(raw)) return undefined; return this.client.nodes[nodeIdKey(BigInt(raw))]; } private _nodeEndpoints(target: MatterNode): number[] { const eps = new Set(); for (const key of Object.keys(target.attributes)) { const m = /^(\d+)\/29\/0$/.exec(key); if (m) eps.add(Number(m[1])); } return Array.from(eps).sort((a, b) => a - b); } private _clusterLabel(id: number): string { return `${clusters[id]?.label ?? "Cluster"} (0x${id.toString(16).padStart(2, "0").toUpperCase()})`; } private _onNodeSelect(e: Event) { const select = e.target as HTMLSelectElement; this._nodeIdInput = select.value; this._endpointInput = ""; this._clusterSelection = ALL_CLUSTERS; } private async _add() { const target = this._resolveTarget(); const rawNodeId = this._nodeIdInput.trim(); if (!/^\d+$/.test(rawNodeId) || BigInt(rawNodeId) <= 0n) { await showAlertDialog({ title: "Validation error", text: "Please enter a valid target node id." }); return; } const targetNodeId = BigInt(rawNodeId); const endpoint = parseInt(this._endpointInput, 10); if (Number.isNaN(endpoint) || endpoint < 0 || endpoint > 0xfffe) { await showAlertDialog({ title: "Validation error", text: "Please enter a valid target endpoint." }); return; } let cluster: number | undefined; if (this._clusterSelection === ALL_CLUSTERS) { cluster = undefined; } else if (this._clusterSelection === CUSTOM_CLUSTER) { const c = parseInt(this._customClusterInput, 10); if (Number.isNaN(c) || c < 0 || c > 0x7fff) { await showAlertDialog({ title: "Validation error", text: "Please enter a valid cluster id." }); return; } cluster = c; } else { cluster = parseInt(this._clusterSelection, 10); } if (target) { const capacity = targetAclCapacityForBinding(target, this.node!.node_id); if (!capacity.canAdd) { await showAlertDialog({ title: "Cannot add binding", text: capacity.reason ?? "Target ACL is full." }); return; } } this._busy = true; try { await addBinding(this.client, this.node!, this.endpoint, targetNodeId, endpoint, cluster); this._close(); } catch (err) { await showAlertDialog({ title: "Failed to add binding", text: err instanceof Error ? err.message : String(err), }); } finally { this._busy = false; } } private _close() { this.shadowRoot!.querySelector("md-dialog")!.close(); } private _handleClosed() { this.parentNode?.removeChild(this); } private _renderClusterField(target: MatterNode | undefined, endpoint: number | undefined) { const known = target !== undefined && endpoint !== undefined && !Number.isNaN(endpoint); const split = known ? bindableClusters(this.node!, this.endpoint, target, endpoint) : undefined; const nonBindable = split !== undefined && this._clusterSelection !== ALL_CLUSTERS && this._clusterSelection !== CUSTOM_CLUSTER && split.otherTarget.includes(parseInt(this._clusterSelection, 10)); return html` (this._clusterSelection = (e.target as HTMLSelectElement).value)} >
All clusters (any eligible)
${split && split.bindable.length ? html`
— Bindable —
${split.bindable.map( c => html`
${this._clusterLabel(c)}
`, )}` : nothing} ${split && split.otherTarget.length ? html`
— Other target clusters (⚠) —
${split.otherTarget.map( c => html`
${this._clusterLabel(c)}
`, )}` : nothing}
Custom cluster id…
${this._clusterSelection === CUSTOM_CLUSTER ? html` (this._customClusterInput = (e.target as HTMLInputElement).value)} >` : nothing} ${nonBindable ? html`
⚠ This cluster is not a client cluster on the source endpoint. The binding may not function — it will be added anyway on your request.
` : nothing} `; } protected override render() { if (!this.node) return nothing; const target = this._resolveTarget(); const endpoint = this._endpointInput === "" ? undefined : parseInt(this._endpointInput, 10); const endpoints = target ? this._nodeEndpoints(target) : []; return html`
Add binding
— pick a node —
${this._knownNodes().map( n => html`
${n.node_id.toString()} · ${getDeviceName(n)}
`, )}
{ this._nodeIdInput = (e.target as HTMLInputElement).value; this._endpointInput = ""; this._clusterSelection = ALL_CLUSTERS; }} > ${target ? html` { this._endpointInput = (e.target as HTMLSelectElement).value; this._clusterSelection = ALL_CLUSTERS; }} > ${endpoints.map(ep => { const dt = getEndpointDeviceTypes(target, ep)[0]; return html`
EP ${ep}${dt ? ` · ${dt.label}` : ""}
`; })}
` : html` (this._endpointInput = (e.target as HTMLInputElement).value)} >`} ${this._renderClusterField(target, endpoint)}
this._add())} >Add Cancel
`; } static override styles = css` .form { display: flex; flex-direction: column; gap: 12px; min-width: 320px; } .warn { font-size: 12px; padding: 8px 10px; border-radius: 7px; background: var(--md-sys-color-error-container); color: var(--md-sys-color-on-error-container); } `; } declare global { interface HTMLElementTagNameMap { "node-binding-dialog": NodeBindingDialog; } }