@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
@@ -13,19 +13,18 @@ import "@material/web/list/list";
13
13
  import "@material/web/list/list-item";
14
14
  import { consume } from "@lit/context";
15
15
  import { MatterClient, MatterNode, UpdateSource } from "@matter-server/ws-client";
16
- import { mdiChatProcessing, mdiLink, mdiPencil, mdiShareVariant, mdiTrashCan, mdiUpdate, mdiVideo } from "@mdi/js";
16
+ import { mdiChatProcessing, mdiPencil, mdiShareVariant, mdiTrashCan, mdiUpdate, mdiVideo } from "@mdi/js";
17
17
  import { LitElement, css, html, nothing } from "lit";
18
18
  import { customElement, property, state } from "lit/decorators.js";
19
19
  import { clientContext, tickContext } from "../../client/client-context.js";
20
20
  import { DeviceType } from "../../client/models/descriptions.js";
21
21
  import { showAlertDialog, showPromptDialog } from "../../components/dialog-box/show-dialog-box.js";
22
- import { showNodeBindingDialog } from "../../components/dialogs/binding/show-node-binding-dialog.js";
23
22
  import { showNodeLabelDialog } from "../../components/dialogs/node-label-dialog/show-node-label-dialog.js";
24
23
  import { handleAsync } from "../../util/async-handler.js";
25
24
  import "../../components/ha-svg-icon";
26
25
  import "../camera-overlay.js";
27
26
  import { getDeviceIcon } from "../../util/device-icons.js";
28
- import { getEndpointDeviceTypes } from "../matter-endpoint-view.js";
27
+ import { getEndpointDeviceTypes } from "../../util/endpoints.js";
29
28
  import { bindingContext } from "./context.js";
30
29
 
31
30
  /** Map updateState values to user-friendly labels */
@@ -79,7 +78,6 @@ export class NodeDetails extends LitElement {
79
78
  protected override render() {
80
79
  if (!this.node) return html``;
81
80
 
82
- const bindings = this.node.attributes[this.endpoint + "/30/0"];
83
81
  const deviceTypeIds = getEndpointDeviceTypes(this.node, this.endpoint).map(d => d.id);
84
82
  const isCamera = deviceTypeIds.includes(0x0142) || deviceTypeIds.includes(0x0143);
85
83
 
@@ -159,15 +157,6 @@ export class NodeDetails extends LitElement {
159
157
  </md-outlined-button>
160
158
  `
161
159
  : nothing}
162
- ${bindings
163
- ? html`
164
- <md-outlined-button @click=${handleAsync(() => this._binding())}>
165
- Binding
166
- <ha-svg-icon slot="icon" .path=${mdiLink}></ha-svg-icon>
167
- </md-outlined-button>
168
- `
169
- : nothing}
170
-
171
160
  <md-outlined-button @click=${handleAsync(() => this._openCommissioningWindow())}
172
161
  >Share<ha-svg-icon slot="icon" .path=${mdiShareVariant}></ha-svg-icon
173
162
  ></md-outlined-button>
@@ -231,14 +220,6 @@ export class NodeDetails extends LitElement {
231
220
  }
232
221
  }
233
222
 
234
- private async _binding() {
235
- try {
236
- showNodeBindingDialog(this.node!, this.endpoint);
237
- } catch (err: unknown) {
238
- console.error("Binding error:", err);
239
- }
240
- }
241
-
242
223
  private _openCameraOverlay(): void {
243
224
  const overlay = document.createElement("camera-overlay");
244
225
  overlay.nodeId = this.node!.node_id;
@@ -43,13 +43,13 @@ export class ServerDetails extends LitElement {
43
43
  </md-list-item>
44
44
  <md-list-item>
45
45
  <div slot="supporting-text">
46
- <div class="left">FabricId: </div>${this.client.serverInfo.fabric_id}
46
+ <div class="left">Matter Server Version: </div>${this.client.serverInfo.sdk_version}
47
47
  </div>
48
48
  <div slot="supporting-text">
49
- <div class="left">Compressed FabricId: </div>${this.client.serverInfo.compressed_fabric_id}
49
+ <div class="left">FabricId: </div>${this.client.serverInfo.fabric_id}
50
50
  </div>
51
51
  <div slot="supporting-text">
52
- <div class="left">SDK Wheels Version: </div>${this.client.serverInfo.sdk_version}
52
+ <div class="left">Compressed FabricId: </div>${this.client.serverInfo.compressed_fabric_id}
53
53
  </div>
54
54
  <div slot="supporting-text">
55
55
  <div class="left">Schema Version: </div>${this.client.serverInfo.schema_version}
@@ -351,7 +351,10 @@ class MatterClusterView extends LitElement {
351
351
 
352
352
  private _renderClusterCommands() {
353
353
  if (this.cluster === undefined) return html``;
354
- if (!this.node?.available) return html``; // Don't show commands when device is offline
354
+ // ACL (31) and Binding (30) panels stay visible read-only for offline nodes; commands for
355
+ // other clusters are hidden while the device is unreachable.
356
+ const RENDER_WHEN_OFFLINE = new Set<number>([30, 31]);
357
+ if (!this.node?.available && !RENDER_WHEN_OFFLINE.has(this.cluster)) return html``;
355
358
 
356
359
  const tagName = getClusterCommandsTag(this.cluster);
357
360
  if (!tagName) return html``;
@@ -13,12 +13,14 @@ import "@material/web/list/list-item";
13
13
  import { consume } from "@lit/context";
14
14
  import { MatterClient, MatterNode, isTestNodeId } from "@matter-server/ws-client";
15
15
  import { mdiAlertCircleOutline, mdiChevronRight } from "@mdi/js";
16
- import { LitElement, css, html } from "lit";
16
+ import { LitElement, css, html, nothing } from "lit";
17
17
  import { customElement, property } from "lit/decorators.js";
18
18
  import { guard } from "lit/directives/guard.js";
19
+ import "./cluster-commands/clusters/binding-commands.js";
19
20
  import { clientContext, tickContext } from "../client/client-context.js";
20
- import { DeviceType, clusters, device_types } from "../client/models/descriptions.js";
21
+ import { clusters } from "../client/models/descriptions.js";
21
22
  import "../components/ha-svg-icon";
23
+ import { getEndpointDeviceTypes } from "../util/endpoints.js";
22
24
  import { formatHex, formatNodeAddress, getEffectiveFabricIndex } from "../util/format_hex.js";
23
25
  import { notFoundStyles } from "../util/shared-styles.js";
24
26
  import { bindingContext } from "./components/context.js";
@@ -41,14 +43,7 @@ function getUniqueClusters(node: MatterNode, endpoint: number) {
41
43
  });
42
44
  }
43
45
 
44
- export function getEndpointDeviceTypes(node: MatterNode, endpoint: number): DeviceType[] {
45
- const rawValues = node.attributes[`${endpoint}/29/0`] as Record<string, number>[] | undefined;
46
- if (!rawValues) return [];
47
- return rawValues.map(rawValue => {
48
- const id = rawValue["0"] ?? rawValue["deviceType"];
49
- return device_types[id] ?? { id: id ?? -1, label: `Unknown Device Type (${id})`, clusters: [] };
50
- });
51
- }
46
+ export { getEndpointDeviceTypes };
52
47
 
53
48
  @customElement("matter-endpoint-view")
54
49
  class MatterEndpointView extends LitElement {
@@ -95,6 +90,17 @@ class MatterEndpointView extends LitElement {
95
90
  <node-details .node=${this.node}></node-details>
96
91
  </div>
97
92
 
93
+ <!-- Binding editor (when this endpoint has a Binding cluster) -->
94
+ ${getUniqueClusters(this.node, this.endpoint).includes(30)
95
+ ? html`<div class="container">
96
+ <binding-cluster-commands
97
+ .node=${this.node}
98
+ .endpoint=${this.endpoint}
99
+ .cluster=${30}
100
+ ></binding-cluster-commands>
101
+ </div>`
102
+ : nothing}
103
+
98
104
  <!-- Endpoint clusters listing -->
99
105
  <div class="container">
100
106
  <md-list>
@@ -18,11 +18,11 @@ import { guard } from "lit/directives/guard.js";
18
18
  import { clientContext, tickContext } from "../client/client-context.js";
19
19
  import "../components/ha-svg-icon";
20
20
  import { getDeviceIcon, getEndpointIcon } from "../util/device-icons.js";
21
+ import { getEndpointDeviceTypes } from "../util/endpoints.js";
21
22
  import { formatNodeAddress, getEffectiveFabricIndex } from "../util/format_hex.js";
22
- import { notFoundStyles, reducedMotionStyles } from "../util/shared-styles.js";
23
23
  import "./components/header";
24
24
  import "./components/node-details";
25
- import { getEndpointDeviceTypes } from "./matter-endpoint-view.js";
25
+ import { notFoundStyles, reducedMotionStyles } from "../util/shared-styles.js";
26
26
  import { getNetworkType } from "./network/network-utils.js";
27
27
 
28
28
  declare global {
@@ -707,24 +707,7 @@ export function stripMdnsHostname(hostname: string): string {
707
707
  return hostname.replace(/\.$/, "").replace(/\.local$/i, "");
708
708
  }
709
709
 
710
- /**
711
- * Gets a human-readable display name for a node.
712
- * Format: nodeLabel || productName (serialNumber)
713
- */
714
- export function getDeviceName(node: MatterNode): string {
715
- if (node.nodeLabel) {
716
- return node.nodeLabel;
717
- }
718
-
719
- const productName = node.productName || "Unknown Device";
720
- const serialNumber = node.serialNumber;
721
-
722
- if (serialNumber) {
723
- return `${productName} (${serialNumber})`;
724
- }
725
-
726
- return productName;
727
- }
710
+ export { getDeviceName } from "../../util/node-name.js";
728
711
 
729
712
  /**
730
713
  * Gets the human-readable name for a Thread routing role.
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type { MatterNode } from "@matter-server/ws-client";
8
+ import { AccessControlEntryDataTransformer, type AccessControlEntryStruct } from "../components/dialogs/acl/model.js";
9
+
10
+ export enum Privilege {
11
+ View = 1,
12
+ ProxyView = 2,
13
+ Operate = 3,
14
+ Manage = 4,
15
+ Administer = 5,
16
+ }
17
+
18
+ export enum AuthMode {
19
+ Pase = 1,
20
+ Case = 2,
21
+ Group = 3,
22
+ }
23
+
24
+ export const PRIVILEGE_NAMES: Record<number, string> = {
25
+ [Privilege.View]: "View",
26
+ [Privilege.ProxyView]: "ProxyView",
27
+ [Privilege.Operate]: "Operate",
28
+ [Privilege.Manage]: "Manage",
29
+ [Privilege.Administer]: "Administer",
30
+ };
31
+
32
+ export const AUTH_MODE_NAMES: Record<number, string> = {
33
+ [AuthMode.Pase]: "PASE",
34
+ [AuthMode.Case]: "CASE",
35
+ [AuthMode.Group]: "Group",
36
+ };
37
+
38
+ export function nodeIdKey(id: number | bigint): string {
39
+ return String(id);
40
+ }
41
+
42
+ /** Normalize a raw attribute value (array or index-keyed object, or absent) into an element array. */
43
+ export function attributeArray(value: unknown): unknown[] {
44
+ if (Array.isArray(value)) return value;
45
+ if (value && typeof value === "object") return Object.values(value);
46
+ return new Array<unknown>();
47
+ }
48
+
49
+ export function readAclEntries(node: MatterNode): AccessControlEntryStruct[] {
50
+ return attributeArray(node.attributes["0/31/0"]).map(value => AccessControlEntryDataTransformer.transform(value));
51
+ }
52
+
53
+ export function entriesForFabric(
54
+ entries: AccessControlEntryStruct[],
55
+ fabricIndex: number | undefined,
56
+ ): AccessControlEntryStruct[] {
57
+ if (fabricIndex === undefined) return entries;
58
+ return entries.filter(e => e.fabricIndex === fabricIndex);
59
+ }
60
+
61
+ /**
62
+ * The device-side fabric index for our controller's fabric, read from CurrentFabricIndex (0/62/5).
63
+ * ACL/Binding entries carry this index in their fabricIndex field — NOT the controller's own
64
+ * fabric-table index (serverInfo.fabric_index), which lives in a different numbering space.
65
+ */
66
+ export function nodeFabricIndex(node: MatterNode): number | undefined {
67
+ const v = node.attributes["0/62/5"];
68
+ return typeof v === "number" ? v : undefined;
69
+ }
70
+
71
+ export function isWholeNode(entry: AccessControlEntryStruct): boolean {
72
+ return !entry.targets || entry.targets.length === 0;
73
+ }
74
+
75
+ /**
76
+ * Whether the entry grants access to (endpoint, cluster). A null target endpoint/cluster is an ACL
77
+ * wildcard (grants all). Cluster matching is directional: a wildcard *request* (cluster undefined,
78
+ * i.e. an all-clusters binding) is only covered by a wildcard ACL target — a cluster-specific grant
79
+ * does not cover "all clusters".
80
+ */
81
+ export function entryMatchesTarget(
82
+ entry: AccessControlEntryStruct,
83
+ endpoint: number,
84
+ cluster: number | undefined,
85
+ ): boolean {
86
+ if (isWholeNode(entry)) return true;
87
+ return entry.targets!.some(t => {
88
+ const endpointMatch = t.endpoint == null || t.endpoint === endpoint;
89
+ const clusterMatch = cluster == null ? t.cluster == null : t.cluster == null || t.cluster === cluster;
90
+ return endpointMatch && clusterMatch;
91
+ });
92
+ }
93
+
94
+ export interface AclCapacity {
95
+ max: number;
96
+ subjectsMax: number;
97
+ targetsMax: number;
98
+ }
99
+
100
+ export function aclCapacity(node: MatterNode): AclCapacity {
101
+ const num = (key: string, fallback: number) => {
102
+ const v = node.attributes[key];
103
+ return typeof v === "number" ? v : fallback;
104
+ };
105
+ return { max: num("0/31/4", 0), subjectsMax: num("0/31/2", 0), targetsMax: num("0/31/3", 0) };
106
+ }
107
+
108
+ /**
109
+ * Stable structural identity for an ACL entry, used to re-locate it in a freshly-read list before a
110
+ * write (the cache copy and the fresh copy are different objects).
111
+ */
112
+ export function aclEntryKey(entry: AccessControlEntryStruct): string {
113
+ const subjects = (entry.subjects ?? []).map(nodeIdKey).sort();
114
+ const targets = (entry.targets ?? [])
115
+ .map(t => `${t.endpoint ?? ""}:${t.cluster ?? ""}:${t.deviceType ?? ""}`)
116
+ .sort();
117
+ return JSON.stringify([entry.fabricIndex, entry.privilege, entry.authMode, subjects, targets]);
118
+ }
119
+
120
+ export function subjectsInclude(entry: AccessControlEntryStruct, nodeId: number | bigint): boolean {
121
+ const key = nodeIdKey(nodeId);
122
+ return (entry.subjects ?? []).some(s => nodeIdKey(s) === key);
123
+ }
124
+
125
+ export function isProtectedAdmin(
126
+ entry: AccessControlEntryStruct,
127
+ controllerNodeId: number | bigint | undefined,
128
+ ): boolean {
129
+ if (controllerNodeId === undefined) return false;
130
+ return (
131
+ entry.privilege === Privilege.Administer &&
132
+ entry.authMode === AuthMode.Case &&
133
+ subjectsInclude(entry, controllerNodeId)
134
+ );
135
+ }
@@ -0,0 +1,182 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type { MatterNode } from "@matter-server/ws-client";
8
+ import type { AccessControlEntryStruct } from "../components/dialogs/acl/model.js";
9
+ import { BindingEntryDataTransformer, type BindingEntryStruct } from "../components/dialogs/binding/model.js";
10
+ import {
11
+ AuthMode,
12
+ Privilege,
13
+ attributeArray,
14
+ entriesForFabric,
15
+ entryMatchesTarget,
16
+ isWholeNode,
17
+ nodeFabricIndex,
18
+ nodeIdKey,
19
+ readAclEntries,
20
+ subjectsInclude,
21
+ } from "./access-control.js";
22
+
23
+ const BINDING_KEY_RE = /^(\d+)\/30\/0$/;
24
+
25
+ export function readBindings(node: MatterNode, endpoint: number): BindingEntryStruct[] {
26
+ return attributeArray(node.attributes[`${endpoint}/30/0`]).map(value =>
27
+ BindingEntryDataTransformer.transform(value),
28
+ );
29
+ }
30
+
31
+ export interface EndpointBinding {
32
+ endpoint: number;
33
+ binding: BindingEntryStruct;
34
+ }
35
+
36
+ export function readAllBindings(node: MatterNode): EndpointBinding[] {
37
+ const result = new Array<EndpointBinding>();
38
+ for (const key of Object.keys(node.attributes)) {
39
+ const m = BINDING_KEY_RE.exec(key);
40
+ if (!m) continue;
41
+ const endpoint = Number(m[1]);
42
+ for (const value of attributeArray(node.attributes[key])) {
43
+ result.push({ endpoint, binding: BindingEntryDataTransformer.transform(value) });
44
+ }
45
+ }
46
+ return result;
47
+ }
48
+
49
+ function numberList(node: MatterNode, key: string): number[] {
50
+ const raw = node.attributes[key];
51
+ if (!Array.isArray(raw)) return new Array<number>();
52
+ return raw.map(v => Number(v));
53
+ }
54
+
55
+ export function targetServerClusters(node: MatterNode, endpoint: number): number[] {
56
+ return numberList(node, `${endpoint}/29/1`);
57
+ }
58
+
59
+ export function sourceClientClusters(node: MatterNode, endpoint: number): number[] {
60
+ return numberList(node, `${endpoint}/29/2`);
61
+ }
62
+
63
+ export interface BindableClusters {
64
+ bindable: number[];
65
+ otherTarget: number[];
66
+ }
67
+
68
+ export function bindableClusters(
69
+ source: MatterNode,
70
+ sourceEndpoint: number,
71
+ target: MatterNode,
72
+ targetEndpoint: number,
73
+ ): BindableClusters {
74
+ const client = new Set(sourceClientClusters(source, sourceEndpoint));
75
+ const server = targetServerClusters(target, targetEndpoint);
76
+ const bindable = new Array<number>();
77
+ const otherTarget = new Array<number>();
78
+ for (const c of server) {
79
+ if (client.has(c)) bindable.push(c);
80
+ else otherTarget.push(c);
81
+ }
82
+ return { bindable, otherTarget };
83
+ }
84
+
85
+ export type ReverseAclState = "present" | "missing" | "overPrivileged" | "cannotVerify";
86
+
87
+ export interface ReverseAclResult {
88
+ state: ReverseAclState;
89
+ }
90
+
91
+ /**
92
+ * Whether the target node's ACL grants the source the access this binding needs:
93
+ * - present: a matching CASE entry at Operate exists
94
+ * - overPrivileged: the only matching grant is above Operate (Manage/Administer)
95
+ * - missing: no matching grant (or only below Operate)
96
+ * - cannotVerify: target node not known / offline
97
+ */
98
+ export function reverseAclState(
99
+ sourceNodeId: number | bigint,
100
+ binding: BindingEntryStruct,
101
+ targetNode: MatterNode | undefined,
102
+ ): ReverseAclResult {
103
+ if (!targetNode || !targetNode.available) return { state: "cannotVerify" };
104
+ const matching = entriesForFabric(readAclEntries(targetNode), nodeFabricIndex(targetNode)).filter(
105
+ e =>
106
+ e.authMode === AuthMode.Case &&
107
+ subjectsInclude(e, sourceNodeId) &&
108
+ entryMatchesTarget(e, binding.endpoint ?? -1, binding.cluster),
109
+ );
110
+ const granting = matching.filter(e => e.privilege >= Privilege.Operate);
111
+ if (granting.length === 0) return { state: "missing" };
112
+ if (granting.some(e => e.privilege === Privilege.Operate)) return { state: "present" };
113
+ return { state: "overPrivileged" };
114
+ }
115
+
116
+ export type RelationshipKind = "none" | "backs" | "overPrivileged";
117
+
118
+ export interface RelationshipResult {
119
+ kind: RelationshipKind;
120
+ sourceNodeId?: number | bigint;
121
+ sourceEndpoint?: number;
122
+ }
123
+
124
+ /**
125
+ * Whether an ACL entry on the viewed node backs a real binding from one of its subjects. Grants
126
+ * above Operate are flagged over-privileged (Operate is sufficient for a binding).
127
+ */
128
+ export function detectBindingRelationship(
129
+ entry: AccessControlEntryStruct,
130
+ viewedNodeId: number | bigint,
131
+ allNodes: MatterNode[],
132
+ ): RelationshipResult {
133
+ if (entry.authMode !== AuthMode.Case) return { kind: "none" };
134
+ const viewedKey = nodeIdKey(viewedNodeId);
135
+
136
+ for (const subject of entry.subjects ?? []) {
137
+ const sourceKey = nodeIdKey(subject);
138
+ const source = allNodes.find(n => nodeIdKey(n.node_id) === sourceKey);
139
+ if (!source || !source.available) continue;
140
+ for (const { endpoint, binding } of readAllBindings(source)) {
141
+ if (binding.node == null) continue;
142
+ if (nodeIdKey(binding.node) !== viewedKey) continue;
143
+ if (!entryMatchesTarget(entry, binding.endpoint ?? -1, binding.cluster)) continue;
144
+ const kind: RelationshipKind = entry.privilege > Privilege.Operate ? "overPrivileged" : "backs";
145
+ return { kind, sourceNodeId: source.node_id, sourceEndpoint: endpoint };
146
+ }
147
+ }
148
+ return { kind: "none" };
149
+ }
150
+
151
+ export interface AddBindingCapacity {
152
+ canAdd: boolean;
153
+ reason?: string;
154
+ }
155
+
156
+ /**
157
+ * A new binding consumes a target ACL slot only when no existing our-fabric Operate+ entry for the
158
+ * source can absorb it — mirrors the merge behavior the writer implements.
159
+ */
160
+ export function targetAclCapacityForBinding(targetNode: MatterNode, sourceNodeId: number | bigint): AddBindingCapacity {
161
+ const fabricIndex = nodeFabricIndex(targetNode);
162
+ // Advisory pre-check only. If CurrentFabricIndex isn't cached for this target yet, don't block:
163
+ // the write path (ensureBindingAcl → freshOurAcl) reads 0/62/5 fresh and fails cleanly if absent.
164
+ if (fabricIndex === undefined) return { canAdd: true };
165
+ const entries = entriesForFabric(readAclEntries(targetNode), fabricIndex);
166
+ const targetsMaxRaw = targetNode.attributes["0/31/3"];
167
+ const targetsMax = typeof targetsMaxRaw === "number" && targetsMaxRaw > 0 ? targetsMaxRaw : Number.MAX_SAFE_INTEGER;
168
+ const reusable = entries.some(
169
+ e =>
170
+ e.authMode === AuthMode.Case &&
171
+ e.privilege >= Privilege.Operate &&
172
+ subjectsInclude(e, sourceNodeId) &&
173
+ (isWholeNode(e) || (e.targets?.length ?? 0) < targetsMax),
174
+ );
175
+ if (reusable) return { canAdd: true };
176
+ const maxRaw = targetNode.attributes["0/31/4"];
177
+ const max = typeof maxRaw === "number" ? maxRaw : 0;
178
+ if (max > 0 && entries.length >= max) {
179
+ return { canAdd: false, reason: "Target node's access control list is full." };
180
+ }
181
+ return { canAdd: true };
182
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type { MatterNode } from "@matter-server/ws-client";
8
+ import { type DeviceType, device_types } from "../client/models/descriptions.js";
9
+
10
+ export function getEndpointDeviceTypes(node: MatterNode, endpoint: number): DeviceType[] {
11
+ const rawValues = node.attributes[`${endpoint}/29/0`] as Record<string, number>[] | undefined;
12
+ if (!rawValues) return new Array<DeviceType>();
13
+ return rawValues.map(rawValue => {
14
+ const id = rawValue["0"] ?? rawValue["deviceType"];
15
+ return device_types[id] ?? { id: id ?? -1, label: `Unknown Device Type (${id})`, clusters: [] };
16
+ });
17
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import type { MatterNode } from "@matter-server/ws-client";
8
+
9
+ /**
10
+ * Human-readable display name for a node: nodeLabel, else productName (serialNumber), else a
11
+ * generic fallback. Shared so node pickers and tables name devices consistently.
12
+ */
13
+ export function getDeviceName(node: MatterNode): string {
14
+ if (node.nodeLabel) return node.nodeLabel;
15
+ const productName = node.productName || "Unknown Device";
16
+ const serialNumber = node.serialNumber;
17
+ return serialNumber ? `${productName} (${serialNumber})` : productName;
18
+ }