@matter-server/dashboard 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/dist/esm/components/dialogs/acl/acl-actions.d.ts +12 -0
  2. package/dist/esm/components/dialogs/acl/acl-actions.d.ts.map +1 -0
  3. package/dist/esm/components/dialogs/acl/acl-actions.js +56 -0
  4. package/dist/esm/components/dialogs/acl/acl-actions.js.map +6 -0
  5. package/dist/esm/components/dialogs/acl/model.d.ts +2 -2
  6. package/dist/esm/components/dialogs/acl/model.d.ts.map +1 -1
  7. package/dist/esm/components/dialogs/acl/model.js +11 -15
  8. package/dist/esm/components/dialogs/acl/model.js.map +1 -1
  9. package/dist/esm/components/dialogs/acl/node-acl-add-dialog.d.ts +43 -0
  10. package/dist/esm/components/dialogs/acl/node-acl-add-dialog.d.ts.map +1 -0
  11. package/dist/esm/components/dialogs/acl/node-acl-add-dialog.js +353 -0
  12. package/dist/esm/components/dialogs/acl/node-acl-add-dialog.js.map +6 -0
  13. package/dist/esm/components/dialogs/acl/show-node-acl-add-dialog.d.ts +8 -0
  14. package/dist/esm/components/dialogs/acl/show-node-acl-add-dialog.d.ts.map +1 -0
  15. package/dist/esm/components/dialogs/acl/show-node-acl-add-dialog.js +15 -0
  16. package/dist/esm/components/dialogs/acl/show-node-acl-add-dialog.js.map +6 -0
  17. package/dist/esm/components/dialogs/binding/binding-actions.d.ts +17 -0
  18. package/dist/esm/components/dialogs/binding/binding-actions.d.ts.map +1 -0
  19. package/dist/esm/components/dialogs/binding/binding-actions.js +135 -0
  20. package/dist/esm/components/dialogs/binding/binding-actions.js.map +6 -0
  21. package/dist/esm/components/dialogs/binding/model.d.ts +1 -1
  22. package/dist/esm/components/dialogs/binding/model.d.ts.map +1 -1
  23. package/dist/esm/components/dialogs/binding/model.js +8 -3
  24. package/dist/esm/components/dialogs/binding/model.js.map +1 -1
  25. package/dist/esm/components/dialogs/binding/node-binding-dialog.d.ts +16 -24
  26. package/dist/esm/components/dialogs/binding/node-binding-dialog.d.ts.map +1 -1
  27. package/dist/esm/components/dialogs/binding/node-binding-dialog.js +210 -332
  28. package/dist/esm/components/dialogs/binding/node-binding-dialog.js.map +1 -1
  29. package/dist/esm/pages/cluster-commands/clusters/access-control-commands.d.ts +37 -0
  30. package/dist/esm/pages/cluster-commands/clusters/access-control-commands.d.ts.map +1 -0
  31. package/dist/esm/pages/cluster-commands/clusters/access-control-commands.js +387 -0
  32. package/dist/esm/pages/cluster-commands/clusters/access-control-commands.js.map +6 -0
  33. package/dist/esm/pages/cluster-commands/clusters/binding-commands.d.ts +35 -0
  34. package/dist/esm/pages/cluster-commands/clusters/binding-commands.d.ts.map +1 -0
  35. package/dist/esm/pages/cluster-commands/clusters/binding-commands.js +254 -0
  36. package/dist/esm/pages/cluster-commands/clusters/binding-commands.js.map +6 -0
  37. package/dist/esm/pages/cluster-commands/index.d.ts +2 -0
  38. package/dist/esm/pages/cluster-commands/index.d.ts.map +1 -1
  39. package/dist/esm/pages/cluster-commands/index.js +2 -0
  40. package/dist/esm/pages/cluster-commands/index.js.map +1 -1
  41. package/dist/esm/pages/components/node-details.d.ts +0 -1
  42. package/dist/esm/pages/components/node-details.d.ts.map +1 -1
  43. package/dist/esm/pages/components/node-details.js +2 -18
  44. package/dist/esm/pages/components/node-details.js.map +1 -1
  45. package/dist/esm/pages/components/server-details.js +3 -3
  46. package/dist/esm/pages/components/server-details.js.map +1 -1
  47. package/dist/esm/pages/matter-cluster-view.d.ts.map +1 -1
  48. package/dist/esm/pages/matter-cluster-view.js +2 -1
  49. package/dist/esm/pages/matter-cluster-view.js.map +1 -1
  50. package/dist/esm/pages/matter-endpoint-view.d.ts +3 -3
  51. package/dist/esm/pages/matter-endpoint-view.d.ts.map +1 -1
  52. package/dist/esm/pages/matter-endpoint-view.js +13 -10
  53. package/dist/esm/pages/matter-endpoint-view.js.map +1 -1
  54. package/dist/esm/pages/matter-node-view.js +2 -2
  55. package/dist/esm/pages/matter-node-view.js.map +1 -1
  56. package/dist/esm/pages/network/network-utils.d.ts +1 -5
  57. package/dist/esm/pages/network/network-utils.d.ts.map +1 -1
  58. package/dist/esm/pages/network/network-utils.js +1 -11
  59. package/dist/esm/pages/network/network-utils.js.map +1 -1
  60. package/dist/esm/util/access-control.d.ts +54 -0
  61. package/dist/esm/util/access-control.d.ts.map +1 -0
  62. package/dist/esm/util/access-control.js +100 -0
  63. package/dist/esm/util/access-control.js.map +6 -0
  64. package/dist/esm/util/binding.d.ts +54 -0
  65. package/dist/esm/util/binding.d.ts.map +1 -0
  66. package/dist/esm/util/binding.js +113 -0
  67. package/dist/esm/util/binding.js.map +6 -0
  68. package/dist/esm/util/endpoints.d.ts +9 -0
  69. package/dist/esm/util/endpoints.d.ts.map +1 -0
  70. package/dist/esm/util/endpoints.js +18 -0
  71. package/dist/esm/util/endpoints.js.map +6 -0
  72. package/dist/esm/util/node-name.d.ts +12 -0
  73. package/dist/esm/util/node-name.d.ts.map +1 -0
  74. package/dist/esm/util/node-name.js +15 -0
  75. package/dist/esm/util/node-name.js.map +6 -0
  76. package/dist/web/js/{attribute-write-dialog-W7xpCE2E.js → attribute-write-dialog-CqqdRniU.js} +1 -1
  77. package/dist/web/js/{command-invoke-dialog-BAqAAdJw.js → command-invoke-dialog-BuvBOrdC.js} +1 -1
  78. package/dist/web/js/{commission-node-dialog-BTzCGgdy.js → commission-node-dialog-nVZp3go0.js} +5 -5
  79. package/dist/web/js/{commission-node-existing-B2M2hyDh.js → commission-node-existing-Cx3Ahk2t.js} +2 -2
  80. package/dist/web/js/{commission-node-thread-djdz2dXW.js → commission-node-thread-CI8mWWPs.js} +2 -2
  81. package/dist/web/js/{commission-node-wifi-DxAYNS1A.js → commission-node-wifi-lUX4LK2R.js} +2 -2
  82. package/dist/web/js/{dialog-box-tHvPVxDN.js → dialog-box-bAdbnf-T.js} +1 -1
  83. package/dist/web/js/{fire_event-BleYfTLc.js → fire_event-tWhqPfdz.js} +1 -1
  84. package/dist/web/js/main.js +1 -1
  85. package/dist/web/js/{matter-dashboard-app-CVi_GDky.js → matter-dashboard-app-CONA_608.js} +1485 -390
  86. package/dist/web/js/node-acl-add-dialog-DlR-sF-b.js +320 -0
  87. package/dist/web/js/node-binding-dialog-DhnX_86M.js +267 -0
  88. package/dist/web/js/{node-label-dialog-DVZSjsXU.js → node-label-dialog-T3nPG-Qy.js} +1 -1
  89. package/dist/web/js/{settings-dialog-BG5MgZcO.js → settings-dialog-DrHzJtsi.js} +1 -1
  90. package/package.json +4 -4
  91. package/src/components/dialogs/acl/acl-actions.ts +71 -0
  92. package/src/components/dialogs/acl/model.ts +18 -17
  93. package/src/components/dialogs/acl/node-acl-add-dialog.ts +350 -0
  94. package/src/components/dialogs/acl/show-node-acl-add-dialog.ts +14 -0
  95. package/src/components/dialogs/binding/binding-actions.ts +201 -0
  96. package/src/components/dialogs/binding/model.ts +11 -4
  97. package/src/components/dialogs/binding/node-binding-dialog.ts +221 -399
  98. package/src/pages/cluster-commands/clusters/access-control-commands.ts +407 -0
  99. package/src/pages/cluster-commands/clusters/binding-commands.ts +273 -0
  100. package/src/pages/cluster-commands/index.ts +2 -0
  101. package/src/pages/components/node-details.ts +2 -21
  102. package/src/pages/components/server-details.ts +3 -3
  103. package/src/pages/matter-cluster-view.ts +4 -1
  104. package/src/pages/matter-endpoint-view.ts +16 -10
  105. package/src/pages/matter-node-view.ts +2 -2
  106. package/src/pages/network/network-utils.ts +1 -18
  107. package/src/util/access-control.ts +135 -0
  108. package/src/util/binding.ts +182 -0
  109. package/src/util/endpoints.ts +17 -0
  110. package/src/util/node-name.ts +18 -0
  111. package/dist/web/js/node-binding-dialog-B9IdqHrZ.js +0 -624
@@ -6,27 +6,27 @@
6
6
 
7
7
  import "@material/web/button/text-button";
8
8
  import "@material/web/dialog/dialog";
9
- import { consume } from "@lit/context";
10
- import "@material/web/list/list";
11
- import "@material/web/list/list-item";
9
+ import "@material/web/select/outlined-select";
10
+ import "@material/web/select/select-option";
12
11
  import "@material/web/textfield/outlined-text-field";
12
+ import { consume } from "@lit/context";
13
13
  import type { MdDialog } from "@material/web/dialog/dialog.js";
14
- import "../../../components/ha-svg-icon";
15
- import type { MdOutlinedTextField } from "@material/web/textfield/outlined-text-field.js";
16
- import { AccessControlEntry, BindingTarget, MatterClient, MatterNode } from "@matter-server/ws-client";
14
+ import { MatterClient, MatterNode } from "@matter-server/ws-client";
17
15
  import { css, html, LitElement, nothing } from "lit";
18
- import { customElement, property, query } from "lit/decorators.js";
16
+ import { customElement, property, state } from "lit/decorators.js";
19
17
  import { clientContext } from "../../../client/client-context.js";
18
+ import { clusters } from "../../../client/models/descriptions.js";
19
+ import { nodeIdKey } from "../../../util/access-control.js";
20
20
  import { handleAsync } from "../../../util/async-handler.js";
21
- import { analyzeBatchResults, type MatterBatchResult } from "../../../util/matter-status.js";
21
+ import { bindableClusters, targetAclCapacityForBinding } from "../../../util/binding.js";
22
+ import { getEndpointDeviceTypes } from "../../../util/endpoints.js";
23
+ import { getDeviceName } from "../../../util/node-name.js";
22
24
  import { preventDefault } from "../../../util/prevent_default.js";
23
- import { showAlertDialog, showPromptDialog } from "../../dialog-box/show-dialog-box.js";
24
- import {
25
- AccessControlEntryDataTransformer,
26
- AccessControlEntryStruct,
27
- AccessControlTargetStruct,
28
- } from "../acl/model.js";
29
- import { BindingEntryDataTransformer, BindingEntryStruct, InputType } from "./model.js";
25
+ import { showAlertDialog } from "../../dialog-box/show-dialog-box.js";
26
+ import { addBinding } from "./binding-actions.js";
27
+
28
+ const ALL_CLUSTERS = "all";
29
+ const CUSTOM_CLUSTER = "custom";
30
30
 
31
31
  @customElement("node-binding-dialog")
32
32
  export class NodeBindingDialog extends LitElement {
@@ -40,290 +40,95 @@ export class NodeBindingDialog extends LitElement {
40
40
  @property({ attribute: false })
41
41
  endpoint!: number;
42
42
 
43
- @query("md-outlined-text-field[name='NodeId']")
44
- private _targetNodeId!: MdOutlinedTextField;
45
-
46
- @query("md-outlined-text-field[name='Endpoint']")
47
- private _targetEndpoint!: MdOutlinedTextField;
48
-
49
- @query("md-outlined-text-field[name='Cluster']")
50
- private _targetCluster!: MdOutlinedTextField;
51
-
52
- private fetchBindingEntry(): BindingEntryStruct[] {
53
- const bindings_raw = this.node!.attributes[this.endpoint + "/30/0"] as InputType[] | undefined;
54
- if (!bindings_raw) return [];
55
- return Object.values(bindings_raw).map(value => BindingEntryDataTransformer.transform(value));
56
- }
57
-
58
- private fetchACLEntry(targetNodeId: number | bigint): AccessControlEntryStruct[] {
59
- const acl_cluster_raw = this.client.nodes[String(targetNodeId)]?.attributes["0/31/0"] as
60
- | InputType[]
61
- | undefined;
62
- if (!acl_cluster_raw) return [];
63
- return Object.values(acl_cluster_raw).map((value: InputType) =>
64
- AccessControlEntryDataTransformer.transform(value),
65
- );
66
- }
67
-
68
- private async deleteBindingHandler(index: number): Promise<void> {
69
- const rawBindings = this.fetchBindingEntry();
70
- try {
71
- const targetNodeId = rawBindings[index].node;
72
- const endpoint = rawBindings[index].endpoint;
73
- if (targetNodeId === undefined || endpoint === undefined) return;
74
- let aclCleanedUp = false;
75
- try {
76
- await this.removeNodeAtACLEntry(this.node!.node_id, endpoint, targetNodeId);
77
- aclCleanedUp = true;
78
- } catch (aclError) {
79
- const errorMessage = aclError instanceof Error ? aclError.message : String(aclError);
80
- const proceed = await showPromptDialog({
81
- title: "ACL cleanup failed",
82
- text:
83
- `Could not clean up ACL on target node ${targetNodeId}: ${errorMessage}. ` +
84
- "The target node may no longer exist or be unreachable. " +
85
- "Do you want to remove the binding anyway? " +
86
- "Note: The target device may retain an outdated ACL entry.",
87
- confirmText: "Remove binding",
88
- });
89
- if (!proceed) return;
90
- }
91
- try {
92
- const updatedBindings = this.removeBindingAtIndex(rawBindings, index);
93
- await this.syncBindingUpdates(updatedBindings, index);
94
- } catch (bindingError) {
95
- const errorMessage = bindingError instanceof Error ? bindingError.message : String(bindingError);
96
- await showAlertDialog({
97
- title: "Binding removal failed",
98
- text:
99
- `Failed to remove the binding: ${errorMessage}. ` +
100
- (aclCleanedUp
101
- ? "The ACL on the target device was already updated. " +
102
- "The binding and ACL may now be out of sync."
103
- : "No changes were made."),
104
- });
105
- return;
106
- }
107
- } catch (error) {
108
- this.handleBindingDeletionError(error);
109
- }
110
- }
111
-
112
- private async removeNodeAtACLEntry(
113
- sourceNodeId: number | bigint,
114
- sourceEndpoint: number,
115
- targetNodeId: number | bigint,
116
- ): Promise<void> {
117
- const aclEntries = this.fetchACLEntry(targetNodeId);
118
-
119
- const updatedACLEntries = aclEntries
120
- .map(entry => this.removeEntryAtACL(sourceNodeId, sourceEndpoint, entry))
121
- .filter((entry): entry is AccessControlEntryStruct => entry !== undefined);
122
-
123
- // Convert to API format (without fabricIndex - server handles it)
124
- const apiEntries = updatedACLEntries.map(e => this.toAccessControlEntry(e));
125
- await this.client.setACLEntry(targetNodeId, apiEntries);
43
+ @state() private _nodeIdInput = "";
44
+ @state() private _endpointInput = "";
45
+ @state() private _clusterSelection = ALL_CLUSTERS;
46
+ @state() private _customClusterInput = "";
47
+ @state() private _busy = false;
48
+
49
+ private _knownNodes(): MatterNode[] {
50
+ return Object.values(this.client.nodes)
51
+ .filter(n => nodeIdKey(n.node_id) !== nodeIdKey(this.node!.node_id))
52
+ .sort((a, b) => {
53
+ const x = BigInt(a.node_id);
54
+ const y = BigInt(b.node_id);
55
+ return x < y ? -1 : x > y ? 1 : 0;
56
+ });
126
57
  }
127
58
 
128
- private removeEntryAtACL(
129
- nodeId: number | bigint,
130
- sourceEndpoint: number,
131
- entry: AccessControlEntryStruct,
132
- ): AccessControlEntryStruct | undefined {
133
- const hasSubject = entry.subjects.includes(nodeId);
134
- if (!hasSubject) return entry;
135
-
136
- const hasTarget = entry.targets!.filter(item => item.endpoint === sourceEndpoint);
137
- return hasTarget.length > 0 ? undefined : entry;
138
- }
139
-
140
- private removeBindingAtIndex(bindings: BindingEntryStruct[], index: number): BindingEntryStruct[] {
141
- return [...bindings.slice(0, index), ...bindings.slice(index + 1)];
142
- }
143
-
144
- private async syncBindingUpdates(updatedBindings: BindingEntryStruct[], index: number): Promise<void> {
145
- // Convert to API format (without fabricIndex - server handles it)
146
- const apiBindings = updatedBindings.map(b => this.toBindingTarget(b));
147
- await this.client.setNodeBinding(this.node!.node_id, this.endpoint, apiBindings);
148
-
149
- const attributePath = `${this.endpoint}/30/0`;
150
- const currentBindings = this.node!.attributes[attributePath] as BindingEntryStruct[] | undefined;
151
- const updatedAttributes = {
152
- ...this.node!.attributes,
153
- [attributePath]: currentBindings ? this.removeBindingAtIndex(currentBindings, index) : [],
154
- };
155
-
156
- this.node!.attributes = updatedAttributes;
157
- this.requestUpdate();
59
+ private _resolveTarget(): MatterNode | undefined {
60
+ const raw = this._nodeIdInput.trim();
61
+ if (!/^\d+$/.test(raw)) return undefined;
62
+ return this.client.nodes[nodeIdKey(BigInt(raw))];
158
63
  }
159
64
 
160
- private handleBindingDeletionError(error: unknown): void {
161
- const errorMessage = error instanceof Error ? error.message : String(error);
162
- console.error(`Binding deletion failed: ${errorMessage}`);
163
- }
164
-
165
- private async add_target_acl(
166
- targetNodeId: number | bigint,
167
- entry: AccessControlEntryStruct,
168
- ): Promise<MatterBatchResult> {
169
- try {
170
- // Fetch existing ACL entries and transform to local struct format
171
- const rawEntries = this.client.nodes[String(targetNodeId)]?.attributes["0/31/0"] as InputType[] | undefined;
172
- const entries = rawEntries
173
- ? Object.values(rawEntries).map(v => AccessControlEntryDataTransformer.transform(v))
174
- : [];
175
- entries.push(entry);
176
-
177
- // Convert to API format (without fabricIndex - server handles it)
178
- const apiEntries = entries.map(e => this.toAccessControlEntry(e));
179
- const results = await this.client.setACLEntry(targetNodeId, apiEntries);
180
-
181
- const batchResult = analyzeBatchResults(results);
182
- if (batchResult.outcome !== "all_success") {
183
- console.error(`Set ACL entry: ${batchResult.message}`);
184
- }
185
- return batchResult;
186
- } catch (err) {
187
- console.error("Add ACL error:", err);
188
- return {
189
- outcome: "all_failed",
190
- successCount: 0,
191
- failureCount: 1,
192
- errorCounts: { 1: 1 },
193
- message: `Exception: ${err instanceof Error ? err.message : String(err)}`,
194
- };
65
+ private _nodeEndpoints(target: MatterNode): number[] {
66
+ const eps = new Set<number>();
67
+ for (const key of Object.keys(target.attributes)) {
68
+ const m = /^(\d+)\/29\/0$/.exec(key);
69
+ if (m) eps.add(Number(m[1]));
195
70
  }
71
+ return Array.from(eps).sort((a, b) => a - b);
196
72
  }
197
73
 
198
- /** Convert local BindingEntryStruct to API BindingTarget (without fabricIndex) */
199
- private toBindingTarget(entry: BindingEntryStruct): BindingTarget {
200
- return {
201
- node: entry.node ?? null,
202
- group: entry.group ?? null,
203
- endpoint: entry.endpoint ?? null,
204
- cluster: entry.cluster ?? null,
205
- };
74
+ private _clusterLabel(id: number): string {
75
+ return `${clusters[id]?.label ?? "Cluster"} (0x${id.toString(16).padStart(2, "0").toUpperCase()})`;
206
76
  }
207
77
 
208
- /** Convert local AccessControlEntryStruct to API AccessControlEntry (without fabricIndex) */
209
- private toAccessControlEntry(entry: AccessControlEntryStruct): AccessControlEntry {
210
- return {
211
- privilege: entry.privilege,
212
- auth_mode: entry.authMode,
213
- subjects: entry.subjects ?? null,
214
- targets:
215
- entry.targets?.map(t => ({
216
- cluster: t.cluster ?? null,
217
- endpoint: t.endpoint ?? null,
218
- device_type: t.deviceType ?? null,
219
- })) ?? null,
220
- };
78
+ private _onNodeSelect(e: Event) {
79
+ const select = e.target as HTMLSelectElement;
80
+ this._nodeIdInput = select.value;
81
+ this._endpointInput = "";
82
+ this._clusterSelection = ALL_CLUSTERS;
221
83
  }
222
84
 
223
- private async add_bindings(endpoint: number, bindingEntry: BindingEntryStruct): Promise<MatterBatchResult> {
224
- const bindings = this.fetchBindingEntry();
225
- bindings.push(bindingEntry);
226
- try {
227
- // Convert to API format (without fabricIndex - server handles it)
228
- const apiBindings = bindings.map(b => this.toBindingTarget(b));
229
- const results = await this.client.setNodeBinding(this.node!.node_id, endpoint, apiBindings);
230
-
231
- const batchResult = analyzeBatchResults(results);
232
- if (batchResult.outcome !== "all_success") {
233
- console.error(`Set binding: ${batchResult.message}`);
234
- }
235
- return batchResult;
236
- } catch (err) {
237
- console.error("Add bindings error:", err);
238
- return {
239
- outcome: "all_failed",
240
- successCount: 0,
241
- failureCount: 1,
242
- errorCounts: { 1: 1 },
243
- message: `Exception: ${err instanceof Error ? err.message : String(err)}`,
244
- };
245
- }
246
- }
247
-
248
- async addBindingHandler() {
249
- let targetNodeId: bigint | undefined;
250
- const rawNodeId = this._targetNodeId.value?.trim();
251
- if (rawNodeId) {
252
- if (!/^\d+$/.test(rawNodeId)) {
253
- showAlertDialog({ title: "Validation error", text: "Please enter a valid target node ID" });
254
- return;
255
- }
256
- targetNodeId = BigInt(rawNodeId);
257
- }
258
- const targetEndpoint = this._targetEndpoint.value ? parseInt(this._targetEndpoint.value, 10) : undefined;
259
- const targetCluster = this._targetCluster.value ? parseInt(this._targetCluster.value, 10) : undefined;
260
-
261
- if (targetNodeId === undefined || targetNodeId <= 0n) {
262
- showAlertDialog({ title: "Validation error", text: "Please enter a valid target node ID" });
85
+ private async _add() {
86
+ const target = this._resolveTarget();
87
+ const rawNodeId = this._nodeIdInput.trim();
88
+ if (!/^\d+$/.test(rawNodeId) || BigInt(rawNodeId) <= 0n) {
89
+ await showAlertDialog({ title: "Validation error", text: "Please enter a valid target node id." });
263
90
  return;
264
91
  }
265
-
266
- if (targetEndpoint === undefined || targetEndpoint <= 0 || targetEndpoint > 0xfffe) {
267
- showAlertDialog({ title: "Validation error", text: "Please enter a valid target endpoint" });
92
+ const targetNodeId = BigInt(rawNodeId);
93
+ const endpoint = parseInt(this._endpointInput, 10);
94
+ if (Number.isNaN(endpoint) || endpoint < 0 || endpoint > 0xfffe) {
95
+ await showAlertDialog({ title: "Validation error", text: "Please enter a valid target endpoint." });
268
96
  return;
269
97
  }
270
98
 
271
- // cluster optional
272
- if (targetCluster !== undefined) {
273
- // We ignore vendor specific clusters for now
274
- if (targetCluster < 0 || targetCluster > 0x7fff) {
275
- showAlertDialog({ title: "Validation error", text: "Please enter a valid target cluster" });
99
+ let cluster: number | undefined;
100
+ if (this._clusterSelection === ALL_CLUSTERS) {
101
+ cluster = undefined;
102
+ } else if (this._clusterSelection === CUSTOM_CLUSTER) {
103
+ const c = parseInt(this._customClusterInput, 10);
104
+ if (Number.isNaN(c) || c < 0 || c > 0x7fff) {
105
+ await showAlertDialog({ title: "Validation error", text: "Please enter a valid cluster id." });
276
106
  return;
277
107
  }
108
+ cluster = c;
109
+ } else {
110
+ cluster = parseInt(this._clusterSelection, 10);
278
111
  }
279
112
 
280
- const targets: AccessControlTargetStruct = {
281
- endpoint: targetEndpoint,
282
- cluster: targetCluster,
283
- deviceType: undefined,
284
- };
285
-
286
- // Note: fabricIndex is assigned by the server based on the device's fabric table
287
- const acl_entry: AccessControlEntryStruct = {
288
- privilege: 3,
289
- authMode: 2,
290
- subjects: [this.node!.node_id],
291
- targets: [targets],
292
- fabricIndex: 0, // Placeholder - server will use correct fabric index
293
- };
294
-
295
- const aclResult = await this.add_target_acl(targetNodeId, acl_entry);
296
- if (aclResult.outcome === "all_failed") {
297
- showAlertDialog({ title: "Failed to add ACL entry", text: aclResult.message });
298
- return;
299
- }
300
- if (aclResult.outcome === "partial") {
301
- showAlertDialog({ title: "ACL entry partially failed", text: aclResult.message });
302
- // Continue with binding attempt since some ACL entries succeeded
113
+ if (target) {
114
+ const capacity = targetAclCapacityForBinding(target, this.node!.node_id);
115
+ if (!capacity.canAdd) {
116
+ await showAlertDialog({ title: "Cannot add binding", text: capacity.reason ?? "Target ACL is full." });
117
+ return;
118
+ }
303
119
  }
304
120
 
305
- const endpoint = this.endpoint;
306
- // Note: fabricIndex is assigned by the server based on the device's fabric table
307
- const bindingEntry: BindingEntryStruct = {
308
- node: targetNodeId,
309
- endpoint: targetEndpoint,
310
- group: undefined,
311
- cluster: targetCluster,
312
- fabricIndex: undefined, // Server will use correct fabric index
313
- };
314
-
315
- const bindingResult = await this.add_bindings(endpoint, bindingEntry);
316
-
317
- if (bindingResult.outcome === "all_success") {
318
- this._targetNodeId.value = "";
319
- this._targetEndpoint.value = "";
320
- this._targetCluster.value = "";
321
- this.requestUpdate();
322
- } else if (bindingResult.outcome === "partial") {
323
- showAlertDialog({ title: "Binding partially failed", text: bindingResult.message });
324
- this.requestUpdate(); // Update UI to show what succeeded
325
- } else {
326
- showAlertDialog({ title: "Failed to add binding", text: bindingResult.message });
121
+ this._busy = true;
122
+ try {
123
+ await addBinding(this.client, this.node!, this.endpoint, targetNodeId, endpoint, cluster);
124
+ this._close();
125
+ } catch (err) {
126
+ await showAlertDialog({
127
+ title: "Failed to add binding",
128
+ text: err instanceof Error ? err.message : String(err),
129
+ });
130
+ } finally {
131
+ this._busy = false;
327
132
  }
328
133
  }
329
134
 
@@ -332,149 +137,166 @@ export class NodeBindingDialog extends LitElement {
332
137
  }
333
138
 
334
139
  private _handleClosed() {
335
- this.parentNode!.removeChild(this);
140
+ this.parentNode?.removeChild(this);
336
141
  }
337
142
 
338
- private onChange(e: Event) {
339
- const textfield = e.target as MdOutlinedTextField;
340
- if (textfield.type === "number" && textfield.max && textfield.min) {
341
- const value = parseInt(textfield.value, 10);
342
- if (parseInt(textfield.max, 10) < value || value < parseInt(textfield.min, 10)) {
343
- textfield.error = true;
344
- textfield.errorText = "value error";
345
- } else {
346
- textfield.error = false;
347
- }
348
- } else {
349
- // Text field with pattern validation (e.g. node ID)
350
- textfield.error = textfield.value !== "" && !/^[0-9]+$/.test(textfield.value);
351
- if (textfield.error) {
352
- textfield.errorText = "must be a numeric value";
353
- }
354
- }
143
+ private _renderClusterField(target: MatterNode | undefined, endpoint: number | undefined) {
144
+ const known = target !== undefined && endpoint !== undefined && !Number.isNaN(endpoint);
145
+ const split = known ? bindableClusters(this.node!, this.endpoint, target, endpoint) : undefined;
146
+ const nonBindable =
147
+ split !== undefined &&
148
+ this._clusterSelection !== ALL_CLUSTERS &&
149
+ this._clusterSelection !== CUSTOM_CLUSTER &&
150
+ split.otherTarget.includes(parseInt(this._clusterSelection, 10));
151
+
152
+ return html`
153
+ <md-outlined-select
154
+ label="Cluster"
155
+ .value=${this._clusterSelection}
156
+ ?disabled=${this._busy}
157
+ @change=${(e: Event) => (this._clusterSelection = (e.target as HTMLSelectElement).value)}
158
+ >
159
+ <md-select-option value=${ALL_CLUSTERS}>
160
+ <div slot="headline">All clusters (any eligible)</div>
161
+ </md-select-option>
162
+ ${split && split.bindable.length
163
+ ? html`<md-select-option disabled><div slot="headline">— Bindable —</div></md-select-option>
164
+ ${split.bindable.map(
165
+ c =>
166
+ html`<md-select-option value=${String(c)}
167
+ ><div slot="headline">${this._clusterLabel(c)}</div></md-select-option
168
+ >`,
169
+ )}`
170
+ : nothing}
171
+ ${split && split.otherTarget.length
172
+ ? html`<md-select-option disabled
173
+ ><div slot="headline">— Other target clusters (⚠) —</div></md-select-option
174
+ >
175
+ ${split.otherTarget.map(
176
+ c =>
177
+ html`<md-select-option value=${String(c)}
178
+ ><div slot="headline">${this._clusterLabel(c)}</div></md-select-option
179
+ >`,
180
+ )}`
181
+ : nothing}
182
+ <md-select-option value=${CUSTOM_CLUSTER}
183
+ ><div slot="headline">Custom cluster id…</div></md-select-option
184
+ >
185
+ </md-outlined-select>
186
+ ${this._clusterSelection === CUSTOM_CLUSTER
187
+ ? html`<md-outlined-text-field
188
+ label="cluster id"
189
+ type="number"
190
+ min="0"
191
+ max="32767"
192
+ .value=${this._customClusterInput}
193
+ ?disabled=${this._busy}
194
+ @input=${(e: Event) => (this._customClusterInput = (e.target as HTMLInputElement).value)}
195
+ ></md-outlined-text-field>`
196
+ : nothing}
197
+ ${nonBindable
198
+ ? html`<div class="warn">
199
+ ⚠ This cluster is not a client cluster on the source endpoint. The binding may not function — it
200
+ will be added anyway on your request.
201
+ </div>`
202
+ : nothing}
203
+ `;
355
204
  }
356
205
 
357
206
  protected override render() {
358
- const rawBindings = this.node!.attributes[this.endpoint + "/30/0"] as InputType[] | undefined;
359
- const bindings = rawBindings
360
- ? Object.values(rawBindings).map(entry => BindingEntryDataTransformer.transform(entry))
361
- : [];
207
+ if (!this.node) return nothing;
208
+ const target = this._resolveTarget();
209
+ const endpoint = this._endpointInput === "" ? undefined : parseInt(this._endpointInput, 10);
210
+ const endpoints = target ? this._nodeEndpoints(target) : [];
362
211
 
363
212
  return html`
364
213
  <md-dialog open @cancel=${preventDefault} @closed=${this._handleClosed}>
365
- <div slot="headline">
366
- <div>Binding</div>
367
- </div>
214
+ <div slot="headline">Add binding</div>
368
215
  <div slot="content">
369
- <div>
370
- <md-list style="padding-bottom:16px;">
371
- ${Object.values(bindings).map(
372
- (entry, index) => html`
373
- <md-list-item class="binding-item">
374
- <div style="display:flex;gap:8px;">
375
- <div>node:${entry["node"]}</div>
376
- <div>endpoint:${entry["endpoint"]}</div>
377
- ${entry["cluster"] ? html` <div>cluster:${entry["cluster"]}</div> ` : nothing}
378
- </div>
379
- <div slot="end">
380
- <md-text-button
381
- @click=${handleAsync(() => this.deleteBindingHandler(index))}
382
- >delete</md-text-button
383
- </div>
384
- </md-list-item>
385
- `,
216
+ <div class="form">
217
+ <md-outlined-select
218
+ label="Known nodes"
219
+ ?disabled=${this._busy}
220
+ .value=${target ? this._nodeIdInput : ""}
221
+ @change=${this._onNodeSelect}
222
+ >
223
+ <md-select-option value=""><div slot="headline">— pick a node —</div></md-select-option>
224
+ ${this._knownNodes().map(
225
+ n =>
226
+ html`<md-select-option value=${nodeIdKey(n.node_id)}>
227
+ <div slot="headline">${n.node_id.toString()} · ${getDeviceName(n)}</div>
228
+ </md-select-option>`,
386
229
  )}
387
- </md-list>
388
- <div class="inline-group">
389
- <div class="group-label">target</div>
390
- <div class="group-input">
391
- <md-outlined-text-field
392
- label="node id"
393
- name="NodeId"
394
- type="text"
395
- pattern="[0-9]+"
396
- class="target-item"
397
- @change=${this.onChange}
398
- supporting-text="required"
399
- ></md-outlined-text-field>
400
- <md-outlined-text-field
401
- label="endpoint"
402
- name="Endpoint"
403
- type="number"
404
- min="0"
405
- max="65534"
406
- @change=${this.onChange}
407
- class="target-item"
408
- supporting-text="required"
409
- ></md-outlined-text-field>
410
- <md-outlined-text-field
411
- label="cluster"
412
- name="Cluster"
413
- type="number"
414
- min="0"
415
- max="32767"
416
- @change=${this.onChange}
417
- class="target-item"
418
- supporting-text="optional"
419
- ></md-outlined-text-field>
420
- </div>
421
- </div>
422
- <div style="margin:8px;">
423
- <span style="font-size: 0.75rem;font-style: italic;font-weight: bold;">
424
- Note: The Cluster ID field is optional according to the Matter specification. If you
425
- leave it blank, the binding applies to all eligible clusters on the target endpoint.
426
- However, some devices may require a specific cluster to be set in order for the binding
427
- to function correctly. If you experience unexpected behavior, try specifying the cluster
428
- explicitly.
429
- </span>
430
- </div>
230
+ </md-outlined-select>
231
+ <md-outlined-text-field
232
+ label="Target node id"
233
+ type="text"
234
+ pattern="[0-9]+"
235
+ supporting-text="required — pick above or enter a raw node id"
236
+ .value=${this._nodeIdInput}
237
+ ?disabled=${this._busy}
238
+ @input=${(e: Event) => {
239
+ this._nodeIdInput = (e.target as HTMLInputElement).value;
240
+ this._endpointInput = "";
241
+ this._clusterSelection = ALL_CLUSTERS;
242
+ }}
243
+ ></md-outlined-text-field>
244
+
245
+ ${target
246
+ ? html`<md-outlined-select
247
+ label="Target endpoint"
248
+ ?disabled=${this._busy}
249
+ .value=${this._endpointInput}
250
+ @change=${(e: Event) => {
251
+ this._endpointInput = (e.target as HTMLSelectElement).value;
252
+ this._clusterSelection = ALL_CLUSTERS;
253
+ }}
254
+ >
255
+ ${endpoints.map(ep => {
256
+ const dt = getEndpointDeviceTypes(target, ep)[0];
257
+ return html`<md-select-option value=${String(ep)}>
258
+ <div slot="headline">EP ${ep}${dt ? ` · ${dt.label}` : ""}</div>
259
+ </md-select-option>`;
260
+ })}
261
+ </md-outlined-select>`
262
+ : html`<md-outlined-text-field
263
+ label="Target endpoint"
264
+ type="number"
265
+ min="0"
266
+ max="65534"
267
+ supporting-text=${this._nodeIdInput.trim() === ""
268
+ ? "enter a node id first"
269
+ : "unknown node enter endpoint manually"}
270
+ ?disabled=${this._busy || this._nodeIdInput.trim() === ""}
271
+ .value=${this._endpointInput}
272
+ @input=${(e: Event) => (this._endpointInput = (e.target as HTMLInputElement).value)}
273
+ ></md-outlined-text-field>`}
274
+ ${this._renderClusterField(target, endpoint)}
431
275
  </div>
432
276
  </div>
433
277
  <div slot="actions">
434
- <md-text-button @click=${handleAsync(() => this.addBindingHandler())}>Add</md-text-button>
435
- <md-text-button @click=${this._close}>Cancel</md-text-button>
278
+ <md-text-button ?disabled=${this._busy} @click=${handleAsync(() => this._add())}
279
+ >Add</md-text-button
280
+ >
281
+ <md-text-button ?disabled=${this._busy} @click=${this._close}>Cancel</md-text-button>
436
282
  </div>
437
283
  </md-dialog>
438
284
  `;
439
285
  }
440
286
 
441
287
  static override styles = css`
442
- .binding-item {
443
- background: var(--md-sys-color-surface-container-high);
444
- }
445
-
446
- .inline-group {
288
+ .form {
447
289
  display: flex;
448
- border: 2px solid var(--md-sys-color-primary);
449
- padding: 1px;
450
- border-radius: 8px;
451
- position: relative;
452
- margin: 8px;
290
+ flex-direction: column;
291
+ gap: 12px;
292
+ min-width: 320px;
453
293
  }
454
-
455
- .group-input {
456
- display: flex;
457
- width: -webkit-fill-available;
458
- }
459
-
460
- .target-item {
461
- display: inline-block;
462
- padding: 16px 8px 8px 8px;
463
- border-radius: 4px;
464
- vertical-align: middle;
465
- min-width: 80px;
466
- text-align: center;
467
- width: -webkit-fill-available;
468
- }
469
-
470
- .group-label {
471
- position: absolute;
472
- left: 16px;
473
- top: -12px;
474
- background: var(--md-sys-color-primary);
475
- color: var(--md-sys-color-on-primary);
476
- padding: 4px 16px;
477
- border-radius: 4px;
294
+ .warn {
295
+ font-size: 12px;
296
+ padding: 8px 10px;
297
+ border-radius: 7px;
298
+ background: var(--md-sys-color-error-container);
299
+ color: var(--md-sys-color-on-error-container);
478
300
  }
479
301
  `;
480
302
  }