@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
@@ -15,377 +15,249 @@ var __decorateClass = (decorators, target, key, kind) => {
15
15
  */
16
16
  import "@material/web/button/text-button";
17
17
  import "@material/web/dialog/dialog";
18
- import { consume } from "@lit/context";
19
- import "@material/web/list/list";
20
- import "@material/web/list/list-item";
18
+ import "@material/web/select/outlined-select";
19
+ import "@material/web/select/select-option";
21
20
  import "@material/web/textfield/outlined-text-field";
22
- import "../../../components/ha-svg-icon";
21
+ import { consume } from "@lit/context";
23
22
  import { css, html, LitElement, nothing } from "lit";
24
- import { customElement, property, query } from "lit/decorators.js";
23
+ import { customElement, property, state } from "lit/decorators.js";
25
24
  import { clientContext } from "../../../client/client-context.js";
25
+ import { clusters } from "../../../client/models/descriptions.js";
26
+ import { nodeIdKey } from "../../../util/access-control.js";
26
27
  import { handleAsync } from "../../../util/async-handler.js";
27
- import { analyzeBatchResults } from "../../../util/matter-status.js";
28
+ import { bindableClusters, targetAclCapacityForBinding } from "../../../util/binding.js";
29
+ import { getEndpointDeviceTypes } from "../../../util/endpoints.js";
30
+ import { getDeviceName } from "../../../util/node-name.js";
28
31
  import { preventDefault } from "../../../util/prevent_default.js";
29
- import { showAlertDialog, showPromptDialog } from "../../dialog-box/show-dialog-box.js";
30
- import {
31
- AccessControlEntryDataTransformer
32
- } from "../acl/model.js";
33
- import { BindingEntryDataTransformer } from "./model.js";
32
+ import { showAlertDialog } from "../../dialog-box/show-dialog-box.js";
33
+ import { addBinding } from "./binding-actions.js";
34
+ const ALL_CLUSTERS = "all";
35
+ const CUSTOM_CLUSTER = "custom";
34
36
  let NodeBindingDialog = class extends LitElement {
35
- fetchBindingEntry() {
36
- const bindings_raw = this.node.attributes[this.endpoint + "/30/0"];
37
- if (!bindings_raw) return [];
38
- return Object.values(bindings_raw).map((value) => BindingEntryDataTransformer.transform(value));
37
+ constructor() {
38
+ super(...arguments);
39
+ this._nodeIdInput = "";
40
+ this._endpointInput = "";
41
+ this._clusterSelection = ALL_CLUSTERS;
42
+ this._customClusterInput = "";
43
+ this._busy = false;
39
44
  }
40
- fetchACLEntry(targetNodeId) {
41
- const acl_cluster_raw = this.client.nodes[String(targetNodeId)]?.attributes["0/31/0"];
42
- if (!acl_cluster_raw) return [];
43
- return Object.values(acl_cluster_raw).map(
44
- (value) => AccessControlEntryDataTransformer.transform(value)
45
- );
45
+ _knownNodes() {
46
+ return Object.values(this.client.nodes).filter((n) => nodeIdKey(n.node_id) !== nodeIdKey(this.node.node_id)).sort((a, b) => {
47
+ const x = BigInt(a.node_id);
48
+ const y = BigInt(b.node_id);
49
+ return x < y ? -1 : x > y ? 1 : 0;
50
+ });
46
51
  }
47
- async deleteBindingHandler(index) {
48
- const rawBindings = this.fetchBindingEntry();
49
- try {
50
- const targetNodeId = rawBindings[index].node;
51
- const endpoint = rawBindings[index].endpoint;
52
- if (targetNodeId === void 0 || endpoint === void 0) return;
53
- let aclCleanedUp = false;
54
- try {
55
- await this.removeNodeAtACLEntry(this.node.node_id, endpoint, targetNodeId);
56
- aclCleanedUp = true;
57
- } catch (aclError) {
58
- const errorMessage = aclError instanceof Error ? aclError.message : String(aclError);
59
- const proceed = await showPromptDialog({
60
- title: "ACL cleanup failed",
61
- text: `Could not clean up ACL on target node ${targetNodeId}: ${errorMessage}. The target node may no longer exist or be unreachable. Do you want to remove the binding anyway? Note: The target device may retain an outdated ACL entry.`,
62
- confirmText: "Remove binding"
63
- });
64
- if (!proceed) return;
65
- }
66
- try {
67
- const updatedBindings = this.removeBindingAtIndex(rawBindings, index);
68
- await this.syncBindingUpdates(updatedBindings, index);
69
- } catch (bindingError) {
70
- const errorMessage = bindingError instanceof Error ? bindingError.message : String(bindingError);
71
- await showAlertDialog({
72
- title: "Binding removal failed",
73
- text: `Failed to remove the binding: ${errorMessage}. ` + (aclCleanedUp ? "The ACL on the target device was already updated. The binding and ACL may now be out of sync." : "No changes were made.")
74
- });
75
- return;
76
- }
77
- } catch (error) {
78
- this.handleBindingDeletionError(error);
79
- }
52
+ _resolveTarget() {
53
+ const raw = this._nodeIdInput.trim();
54
+ if (!/^\d+$/.test(raw)) return void 0;
55
+ return this.client.nodes[nodeIdKey(BigInt(raw))];
80
56
  }
81
- async removeNodeAtACLEntry(sourceNodeId, sourceEndpoint, targetNodeId) {
82
- const aclEntries = this.fetchACLEntry(targetNodeId);
83
- const updatedACLEntries = aclEntries.map((entry) => this.removeEntryAtACL(sourceNodeId, sourceEndpoint, entry)).filter((entry) => entry !== void 0);
84
- const apiEntries = updatedACLEntries.map((e) => this.toAccessControlEntry(e));
85
- await this.client.setACLEntry(targetNodeId, apiEntries);
86
- }
87
- removeEntryAtACL(nodeId, sourceEndpoint, entry) {
88
- const hasSubject = entry.subjects.includes(nodeId);
89
- if (!hasSubject) return entry;
90
- const hasTarget = entry.targets.filter((item) => item.endpoint === sourceEndpoint);
91
- return hasTarget.length > 0 ? void 0 : entry;
92
- }
93
- removeBindingAtIndex(bindings, index) {
94
- return [...bindings.slice(0, index), ...bindings.slice(index + 1)];
95
- }
96
- async syncBindingUpdates(updatedBindings, index) {
97
- const apiBindings = updatedBindings.map((b) => this.toBindingTarget(b));
98
- await this.client.setNodeBinding(this.node.node_id, this.endpoint, apiBindings);
99
- const attributePath = `${this.endpoint}/30/0`;
100
- const currentBindings = this.node.attributes[attributePath];
101
- const updatedAttributes = {
102
- ...this.node.attributes,
103
- [attributePath]: currentBindings ? this.removeBindingAtIndex(currentBindings, index) : []
104
- };
105
- this.node.attributes = updatedAttributes;
106
- this.requestUpdate();
107
- }
108
- handleBindingDeletionError(error) {
109
- const errorMessage = error instanceof Error ? error.message : String(error);
110
- console.error(`Binding deletion failed: ${errorMessage}`);
111
- }
112
- async add_target_acl(targetNodeId, entry) {
113
- try {
114
- const rawEntries = this.client.nodes[String(targetNodeId)]?.attributes["0/31/0"];
115
- const entries = rawEntries ? Object.values(rawEntries).map((v) => AccessControlEntryDataTransformer.transform(v)) : [];
116
- entries.push(entry);
117
- const apiEntries = entries.map((e) => this.toAccessControlEntry(e));
118
- const results = await this.client.setACLEntry(targetNodeId, apiEntries);
119
- const batchResult = analyzeBatchResults(results);
120
- if (batchResult.outcome !== "all_success") {
121
- console.error(`Set ACL entry: ${batchResult.message}`);
122
- }
123
- return batchResult;
124
- } catch (err) {
125
- console.error("Add ACL error:", err);
126
- return {
127
- outcome: "all_failed",
128
- successCount: 0,
129
- failureCount: 1,
130
- errorCounts: { 1: 1 },
131
- message: `Exception: ${err instanceof Error ? err.message : String(err)}`
132
- };
57
+ _nodeEndpoints(target) {
58
+ const eps = /* @__PURE__ */ new Set();
59
+ for (const key of Object.keys(target.attributes)) {
60
+ const m = /^(\d+)\/29\/0$/.exec(key);
61
+ if (m) eps.add(Number(m[1]));
133
62
  }
63
+ return Array.from(eps).sort((a, b) => a - b);
134
64
  }
135
- /** Convert local BindingEntryStruct to API BindingTarget (without fabricIndex) */
136
- toBindingTarget(entry) {
137
- return {
138
- node: entry.node ?? null,
139
- group: entry.group ?? null,
140
- endpoint: entry.endpoint ?? null,
141
- cluster: entry.cluster ?? null
142
- };
65
+ _clusterLabel(id) {
66
+ return `${clusters[id]?.label ?? "Cluster"} (0x${id.toString(16).padStart(2, "0").toUpperCase()})`;
143
67
  }
144
- /** Convert local AccessControlEntryStruct to API AccessControlEntry (without fabricIndex) */
145
- toAccessControlEntry(entry) {
146
- return {
147
- privilege: entry.privilege,
148
- auth_mode: entry.authMode,
149
- subjects: entry.subjects ?? null,
150
- targets: entry.targets?.map((t) => ({
151
- cluster: t.cluster ?? null,
152
- endpoint: t.endpoint ?? null,
153
- device_type: t.deviceType ?? null
154
- })) ?? null
155
- };
156
- }
157
- async add_bindings(endpoint, bindingEntry) {
158
- const bindings = this.fetchBindingEntry();
159
- bindings.push(bindingEntry);
160
- try {
161
- const apiBindings = bindings.map((b) => this.toBindingTarget(b));
162
- const results = await this.client.setNodeBinding(this.node.node_id, endpoint, apiBindings);
163
- const batchResult = analyzeBatchResults(results);
164
- if (batchResult.outcome !== "all_success") {
165
- console.error(`Set binding: ${batchResult.message}`);
166
- }
167
- return batchResult;
168
- } catch (err) {
169
- console.error("Add bindings error:", err);
170
- return {
171
- outcome: "all_failed",
172
- successCount: 0,
173
- failureCount: 1,
174
- errorCounts: { 1: 1 },
175
- message: `Exception: ${err instanceof Error ? err.message : String(err)}`
176
- };
177
- }
68
+ _onNodeSelect(e) {
69
+ const select = e.target;
70
+ this._nodeIdInput = select.value;
71
+ this._endpointInput = "";
72
+ this._clusterSelection = ALL_CLUSTERS;
178
73
  }
179
- async addBindingHandler() {
180
- let targetNodeId;
181
- const rawNodeId = this._targetNodeId.value?.trim();
182
- if (rawNodeId) {
183
- if (!/^\d+$/.test(rawNodeId)) {
184
- showAlertDialog({ title: "Validation error", text: "Please enter a valid target node ID" });
185
- return;
186
- }
187
- targetNodeId = BigInt(rawNodeId);
188
- }
189
- const targetEndpoint = this._targetEndpoint.value ? parseInt(this._targetEndpoint.value, 10) : void 0;
190
- const targetCluster = this._targetCluster.value ? parseInt(this._targetCluster.value, 10) : void 0;
191
- if (targetNodeId === void 0 || targetNodeId <= 0n) {
192
- showAlertDialog({ title: "Validation error", text: "Please enter a valid target node ID" });
74
+ async _add() {
75
+ const target = this._resolveTarget();
76
+ const rawNodeId = this._nodeIdInput.trim();
77
+ if (!/^\d+$/.test(rawNodeId) || BigInt(rawNodeId) <= 0n) {
78
+ await showAlertDialog({ title: "Validation error", text: "Please enter a valid target node id." });
193
79
  return;
194
80
  }
195
- if (targetEndpoint === void 0 || targetEndpoint <= 0 || targetEndpoint > 65534) {
196
- showAlertDialog({ title: "Validation error", text: "Please enter a valid target endpoint" });
81
+ const targetNodeId = BigInt(rawNodeId);
82
+ const endpoint = parseInt(this._endpointInput, 10);
83
+ if (Number.isNaN(endpoint) || endpoint < 0 || endpoint > 65534) {
84
+ await showAlertDialog({ title: "Validation error", text: "Please enter a valid target endpoint." });
197
85
  return;
198
86
  }
199
- if (targetCluster !== void 0) {
200
- if (targetCluster < 0 || targetCluster > 32767) {
201
- showAlertDialog({ title: "Validation error", text: "Please enter a valid target cluster" });
87
+ let cluster;
88
+ if (this._clusterSelection === ALL_CLUSTERS) {
89
+ cluster = void 0;
90
+ } else if (this._clusterSelection === CUSTOM_CLUSTER) {
91
+ const c = parseInt(this._customClusterInput, 10);
92
+ if (Number.isNaN(c) || c < 0 || c > 32767) {
93
+ await showAlertDialog({ title: "Validation error", text: "Please enter a valid cluster id." });
202
94
  return;
203
95
  }
96
+ cluster = c;
97
+ } else {
98
+ cluster = parseInt(this._clusterSelection, 10);
204
99
  }
205
- const targets = {
206
- endpoint: targetEndpoint,
207
- cluster: targetCluster,
208
- deviceType: void 0
209
- };
210
- const acl_entry = {
211
- privilege: 3,
212
- authMode: 2,
213
- subjects: [this.node.node_id],
214
- targets: [targets],
215
- fabricIndex: 0
216
- // Placeholder - server will use correct fabric index
217
- };
218
- const aclResult = await this.add_target_acl(targetNodeId, acl_entry);
219
- if (aclResult.outcome === "all_failed") {
220
- showAlertDialog({ title: "Failed to add ACL entry", text: aclResult.message });
221
- return;
222
- }
223
- if (aclResult.outcome === "partial") {
224
- showAlertDialog({ title: "ACL entry partially failed", text: aclResult.message });
100
+ if (target) {
101
+ const capacity = targetAclCapacityForBinding(target, this.node.node_id);
102
+ if (!capacity.canAdd) {
103
+ await showAlertDialog({ title: "Cannot add binding", text: capacity.reason ?? "Target ACL is full." });
104
+ return;
105
+ }
225
106
  }
226
- const endpoint = this.endpoint;
227
- const bindingEntry = {
228
- node: targetNodeId,
229
- endpoint: targetEndpoint,
230
- group: void 0,
231
- cluster: targetCluster,
232
- fabricIndex: void 0
233
- // Server will use correct fabric index
234
- };
235
- const bindingResult = await this.add_bindings(endpoint, bindingEntry);
236
- if (bindingResult.outcome === "all_success") {
237
- this._targetNodeId.value = "";
238
- this._targetEndpoint.value = "";
239
- this._targetCluster.value = "";
240
- this.requestUpdate();
241
- } else if (bindingResult.outcome === "partial") {
242
- showAlertDialog({ title: "Binding partially failed", text: bindingResult.message });
243
- this.requestUpdate();
244
- } else {
245
- showAlertDialog({ title: "Failed to add binding", text: bindingResult.message });
107
+ this._busy = true;
108
+ try {
109
+ await addBinding(this.client, this.node, this.endpoint, targetNodeId, endpoint, cluster);
110
+ this._close();
111
+ } catch (err) {
112
+ await showAlertDialog({
113
+ title: "Failed to add binding",
114
+ text: err instanceof Error ? err.message : String(err)
115
+ });
116
+ } finally {
117
+ this._busy = false;
246
118
  }
247
119
  }
248
120
  _close() {
249
121
  this.shadowRoot.querySelector("md-dialog").close();
250
122
  }
251
123
  _handleClosed() {
252
- this.parentNode.removeChild(this);
124
+ this.parentNode?.removeChild(this);
253
125
  }
254
- onChange(e) {
255
- const textfield = e.target;
256
- if (textfield.type === "number" && textfield.max && textfield.min) {
257
- const value = parseInt(textfield.value, 10);
258
- if (parseInt(textfield.max, 10) < value || value < parseInt(textfield.min, 10)) {
259
- textfield.error = true;
260
- textfield.errorText = "value error";
261
- } else {
262
- textfield.error = false;
263
- }
264
- } else {
265
- textfield.error = textfield.value !== "" && !/^[0-9]+$/.test(textfield.value);
266
- if (textfield.error) {
267
- textfield.errorText = "must be a numeric value";
268
- }
269
- }
126
+ _renderClusterField(target, endpoint) {
127
+ const known = target !== void 0 && endpoint !== void 0 && !Number.isNaN(endpoint);
128
+ const split = known ? bindableClusters(this.node, this.endpoint, target, endpoint) : void 0;
129
+ const nonBindable = split !== void 0 && this._clusterSelection !== ALL_CLUSTERS && this._clusterSelection !== CUSTOM_CLUSTER && split.otherTarget.includes(parseInt(this._clusterSelection, 10));
130
+ return html`
131
+ <md-outlined-select
132
+ label="Cluster"
133
+ .value=${this._clusterSelection}
134
+ ?disabled=${this._busy}
135
+ @change=${(e) => this._clusterSelection = e.target.value}
136
+ >
137
+ <md-select-option value=${ALL_CLUSTERS}>
138
+ <div slot="headline">All clusters (any eligible)</div>
139
+ </md-select-option>
140
+ ${split && split.bindable.length ? html`<md-select-option disabled><div slot="headline">— Bindable —</div></md-select-option>
141
+ ${split.bindable.map(
142
+ (c) => html`<md-select-option value=${String(c)}
143
+ ><div slot="headline">${this._clusterLabel(c)}</div></md-select-option
144
+ >`
145
+ )}` : nothing}
146
+ ${split && split.otherTarget.length ? html`<md-select-option disabled
147
+ ><div slot="headline">— Other target clusters (⚠) —</div></md-select-option
148
+ >
149
+ ${split.otherTarget.map(
150
+ (c) => html`<md-select-option value=${String(c)}
151
+ ><div slot="headline">${this._clusterLabel(c)}</div></md-select-option
152
+ >`
153
+ )}` : nothing}
154
+ <md-select-option value=${CUSTOM_CLUSTER}
155
+ ><div slot="headline">Custom cluster id…</div></md-select-option
156
+ >
157
+ </md-outlined-select>
158
+ ${this._clusterSelection === CUSTOM_CLUSTER ? html`<md-outlined-text-field
159
+ label="cluster id"
160
+ type="number"
161
+ min="0"
162
+ max="32767"
163
+ .value=${this._customClusterInput}
164
+ ?disabled=${this._busy}
165
+ @input=${(e) => this._customClusterInput = e.target.value}
166
+ ></md-outlined-text-field>` : nothing}
167
+ ${nonBindable ? html`<div class="warn">
168
+ ⚠ This cluster is not a client cluster on the source endpoint. The binding may not function — it
169
+ will be added anyway on your request.
170
+ </div>` : nothing}
171
+ `;
270
172
  }
271
173
  render() {
272
- const rawBindings = this.node.attributes[this.endpoint + "/30/0"];
273
- const bindings = rawBindings ? Object.values(rawBindings).map((entry) => BindingEntryDataTransformer.transform(entry)) : [];
174
+ if (!this.node) return nothing;
175
+ const target = this._resolveTarget();
176
+ const endpoint = this._endpointInput === "" ? void 0 : parseInt(this._endpointInput, 10);
177
+ const endpoints = target ? this._nodeEndpoints(target) : [];
274
178
  return html`
275
179
  <md-dialog open @cancel=${preventDefault} @closed=${this._handleClosed}>
276
- <div slot="headline">
277
- <div>Binding</div>
278
- </div>
180
+ <div slot="headline">Add binding</div>
279
181
  <div slot="content">
280
- <div>
281
- <md-list style="padding-bottom:16px;">
282
- ${Object.values(bindings).map(
283
- (entry, index) => html`
284
- <md-list-item class="binding-item">
285
- <div style="display:flex;gap:8px;">
286
- <div>node:${entry["node"]}</div>
287
- <div>endpoint:${entry["endpoint"]}</div>
288
- ${entry["cluster"] ? html` <div>cluster:${entry["cluster"]}</div> ` : nothing}
289
- </div>
290
- <div slot="end">
291
- <md-text-button
292
- @click=${handleAsync(() => this.deleteBindingHandler(index))}
293
- >delete</md-text-button
294
- </div>
295
- </md-list-item>
296
- `
182
+ <div class="form">
183
+ <md-outlined-select
184
+ label="Known nodes"
185
+ ?disabled=${this._busy}
186
+ .value=${target ? this._nodeIdInput : ""}
187
+ @change=${this._onNodeSelect}
188
+ >
189
+ <md-select-option value=""><div slot="headline">— pick a node —</div></md-select-option>
190
+ ${this._knownNodes().map(
191
+ (n) => html`<md-select-option value=${nodeIdKey(n.node_id)}>
192
+ <div slot="headline">${n.node_id.toString()} · ${getDeviceName(n)}</div>
193
+ </md-select-option>`
297
194
  )}
298
- </md-list>
299
- <div class="inline-group">
300
- <div class="group-label">target</div>
301
- <div class="group-input">
302
- <md-outlined-text-field
303
- label="node id"
304
- name="NodeId"
305
- type="text"
306
- pattern="[0-9]+"
307
- class="target-item"
308
- @change=${this.onChange}
309
- supporting-text="required"
310
- ></md-outlined-text-field>
311
- <md-outlined-text-field
312
- label="endpoint"
313
- name="Endpoint"
314
- type="number"
315
- min="0"
316
- max="65534"
317
- @change=${this.onChange}
318
- class="target-item"
319
- supporting-text="required"
320
- ></md-outlined-text-field>
321
- <md-outlined-text-field
322
- label="cluster"
323
- name="Cluster"
324
- type="number"
325
- min="0"
326
- max="32767"
327
- @change=${this.onChange}
328
- class="target-item"
329
- supporting-text="optional"
330
- ></md-outlined-text-field>
331
- </div>
332
- </div>
333
- <div style="margin:8px;">
334
- <span style="font-size: 0.75rem;font-style: italic;font-weight: bold;">
335
- Note: The Cluster ID field is optional according to the Matter specification. If you
336
- leave it blank, the binding applies to all eligible clusters on the target endpoint.
337
- However, some devices may require a specific cluster to be set in order for the binding
338
- to function correctly. If you experience unexpected behavior, try specifying the cluster
339
- explicitly.
340
- </span>
341
- </div>
195
+ </md-outlined-select>
196
+ <md-outlined-text-field
197
+ label="Target node id"
198
+ type="text"
199
+ pattern="[0-9]+"
200
+ supporting-text="required — pick above or enter a raw node id"
201
+ .value=${this._nodeIdInput}
202
+ ?disabled=${this._busy}
203
+ @input=${(e) => {
204
+ this._nodeIdInput = e.target.value;
205
+ this._endpointInput = "";
206
+ this._clusterSelection = ALL_CLUSTERS;
207
+ }}
208
+ ></md-outlined-text-field>
209
+
210
+ ${target ? html`<md-outlined-select
211
+ label="Target endpoint"
212
+ ?disabled=${this._busy}
213
+ .value=${this._endpointInput}
214
+ @change=${(e) => {
215
+ this._endpointInput = e.target.value;
216
+ this._clusterSelection = ALL_CLUSTERS;
217
+ }}
218
+ >
219
+ ${endpoints.map((ep) => {
220
+ const dt = getEndpointDeviceTypes(target, ep)[0];
221
+ return html`<md-select-option value=${String(ep)}>
222
+ <div slot="headline">EP ${ep}${dt ? ` \xB7 ${dt.label}` : ""}</div>
223
+ </md-select-option>`;
224
+ })}
225
+ </md-outlined-select>` : html`<md-outlined-text-field
226
+ label="Target endpoint"
227
+ type="number"
228
+ min="0"
229
+ max="65534"
230
+ supporting-text=${this._nodeIdInput.trim() === "" ? "enter a node id first" : "unknown node \u2014 enter endpoint manually"}
231
+ ?disabled=${this._busy || this._nodeIdInput.trim() === ""}
232
+ .value=${this._endpointInput}
233
+ @input=${(e) => this._endpointInput = e.target.value}
234
+ ></md-outlined-text-field>`}
235
+ ${this._renderClusterField(target, endpoint)}
342
236
  </div>
343
237
  </div>
344
238
  <div slot="actions">
345
- <md-text-button @click=${handleAsync(() => this.addBindingHandler())}>Add</md-text-button>
346
- <md-text-button @click=${this._close}>Cancel</md-text-button>
239
+ <md-text-button ?disabled=${this._busy} @click=${handleAsync(() => this._add())}
240
+ >Add</md-text-button
241
+ >
242
+ <md-text-button ?disabled=${this._busy} @click=${this._close}>Cancel</md-text-button>
347
243
  </div>
348
244
  </md-dialog>
349
245
  `;
350
246
  }
351
247
  };
352
248
  NodeBindingDialog.styles = css`
353
- .binding-item {
354
- background: var(--md-sys-color-surface-container-high);
355
- }
356
-
357
- .inline-group {
358
- display: flex;
359
- border: 2px solid var(--md-sys-color-primary);
360
- padding: 1px;
361
- border-radius: 8px;
362
- position: relative;
363
- margin: 8px;
364
- }
365
-
366
- .group-input {
249
+ .form {
367
250
  display: flex;
368
- width: -webkit-fill-available;
369
- }
370
-
371
- .target-item {
372
- display: inline-block;
373
- padding: 16px 8px 8px 8px;
374
- border-radius: 4px;
375
- vertical-align: middle;
376
- min-width: 80px;
377
- text-align: center;
378
- width: -webkit-fill-available;
251
+ flex-direction: column;
252
+ gap: 12px;
253
+ min-width: 320px;
379
254
  }
380
-
381
- .group-label {
382
- position: absolute;
383
- left: 16px;
384
- top: -12px;
385
- background: var(--md-sys-color-primary);
386
- color: var(--md-sys-color-on-primary);
387
- padding: 4px 16px;
388
- border-radius: 4px;
255
+ .warn {
256
+ font-size: 12px;
257
+ padding: 8px 10px;
258
+ border-radius: 7px;
259
+ background: var(--md-sys-color-error-container);
260
+ color: var(--md-sys-color-on-error-container);
389
261
  }
390
262
  `;
391
263
  __decorateClass([
@@ -399,14 +271,20 @@ __decorateClass([
399
271
  property({ attribute: false })
400
272
  ], NodeBindingDialog.prototype, "endpoint", 2);
401
273
  __decorateClass([
402
- query("md-outlined-text-field[name='NodeId']")
403
- ], NodeBindingDialog.prototype, "_targetNodeId", 2);
274
+ state()
275
+ ], NodeBindingDialog.prototype, "_nodeIdInput", 2);
276
+ __decorateClass([
277
+ state()
278
+ ], NodeBindingDialog.prototype, "_endpointInput", 2);
279
+ __decorateClass([
280
+ state()
281
+ ], NodeBindingDialog.prototype, "_clusterSelection", 2);
404
282
  __decorateClass([
405
- query("md-outlined-text-field[name='Endpoint']")
406
- ], NodeBindingDialog.prototype, "_targetEndpoint", 2);
283
+ state()
284
+ ], NodeBindingDialog.prototype, "_customClusterInput", 2);
407
285
  __decorateClass([
408
- query("md-outlined-text-field[name='Cluster']")
409
- ], NodeBindingDialog.prototype, "_targetCluster", 2);
286
+ state()
287
+ ], NodeBindingDialog.prototype, "_busy", 2);
410
288
  NodeBindingDialog = __decorateClass([
411
289
  customElement("node-binding-dialog")
412
290
  ], NodeBindingDialog);