@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.
- package/dist/esm/components/dialogs/acl/acl-actions.d.ts +12 -0
- package/dist/esm/components/dialogs/acl/acl-actions.d.ts.map +1 -0
- package/dist/esm/components/dialogs/acl/acl-actions.js +56 -0
- package/dist/esm/components/dialogs/acl/acl-actions.js.map +6 -0
- package/dist/esm/components/dialogs/acl/model.d.ts +2 -2
- package/dist/esm/components/dialogs/acl/model.d.ts.map +1 -1
- package/dist/esm/components/dialogs/acl/model.js +11 -15
- package/dist/esm/components/dialogs/acl/model.js.map +1 -1
- package/dist/esm/components/dialogs/acl/node-acl-add-dialog.d.ts +43 -0
- package/dist/esm/components/dialogs/acl/node-acl-add-dialog.d.ts.map +1 -0
- package/dist/esm/components/dialogs/acl/node-acl-add-dialog.js +353 -0
- package/dist/esm/components/dialogs/acl/node-acl-add-dialog.js.map +6 -0
- package/dist/esm/components/dialogs/acl/show-node-acl-add-dialog.d.ts +8 -0
- package/dist/esm/components/dialogs/acl/show-node-acl-add-dialog.d.ts.map +1 -0
- package/dist/esm/components/dialogs/acl/show-node-acl-add-dialog.js +15 -0
- package/dist/esm/components/dialogs/acl/show-node-acl-add-dialog.js.map +6 -0
- package/dist/esm/components/dialogs/binding/binding-actions.d.ts +17 -0
- package/dist/esm/components/dialogs/binding/binding-actions.d.ts.map +1 -0
- package/dist/esm/components/dialogs/binding/binding-actions.js +135 -0
- package/dist/esm/components/dialogs/binding/binding-actions.js.map +6 -0
- package/dist/esm/components/dialogs/binding/model.d.ts +1 -1
- package/dist/esm/components/dialogs/binding/model.d.ts.map +1 -1
- package/dist/esm/components/dialogs/binding/model.js +8 -3
- package/dist/esm/components/dialogs/binding/model.js.map +1 -1
- package/dist/esm/components/dialogs/binding/node-binding-dialog.d.ts +16 -24
- package/dist/esm/components/dialogs/binding/node-binding-dialog.d.ts.map +1 -1
- package/dist/esm/components/dialogs/binding/node-binding-dialog.js +210 -332
- package/dist/esm/components/dialogs/binding/node-binding-dialog.js.map +1 -1
- package/dist/esm/pages/cluster-commands/clusters/access-control-commands.d.ts +37 -0
- package/dist/esm/pages/cluster-commands/clusters/access-control-commands.d.ts.map +1 -0
- package/dist/esm/pages/cluster-commands/clusters/access-control-commands.js +387 -0
- package/dist/esm/pages/cluster-commands/clusters/access-control-commands.js.map +6 -0
- package/dist/esm/pages/cluster-commands/clusters/binding-commands.d.ts +35 -0
- package/dist/esm/pages/cluster-commands/clusters/binding-commands.d.ts.map +1 -0
- package/dist/esm/pages/cluster-commands/clusters/binding-commands.js +254 -0
- package/dist/esm/pages/cluster-commands/clusters/binding-commands.js.map +6 -0
- package/dist/esm/pages/cluster-commands/index.d.ts +2 -0
- package/dist/esm/pages/cluster-commands/index.d.ts.map +1 -1
- package/dist/esm/pages/cluster-commands/index.js +2 -0
- package/dist/esm/pages/cluster-commands/index.js.map +1 -1
- package/dist/esm/pages/components/node-details.d.ts +0 -1
- package/dist/esm/pages/components/node-details.d.ts.map +1 -1
- package/dist/esm/pages/components/node-details.js +2 -18
- package/dist/esm/pages/components/node-details.js.map +1 -1
- package/dist/esm/pages/components/server-details.js +3 -3
- package/dist/esm/pages/components/server-details.js.map +1 -1
- package/dist/esm/pages/matter-cluster-view.d.ts.map +1 -1
- package/dist/esm/pages/matter-cluster-view.js +2 -1
- package/dist/esm/pages/matter-cluster-view.js.map +1 -1
- package/dist/esm/pages/matter-endpoint-view.d.ts +3 -3
- package/dist/esm/pages/matter-endpoint-view.d.ts.map +1 -1
- package/dist/esm/pages/matter-endpoint-view.js +13 -10
- package/dist/esm/pages/matter-endpoint-view.js.map +1 -1
- package/dist/esm/pages/matter-node-view.js +2 -2
- package/dist/esm/pages/matter-node-view.js.map +1 -1
- package/dist/esm/pages/network/network-utils.d.ts +1 -5
- package/dist/esm/pages/network/network-utils.d.ts.map +1 -1
- package/dist/esm/pages/network/network-utils.js +1 -11
- package/dist/esm/pages/network/network-utils.js.map +1 -1
- package/dist/esm/util/access-control.d.ts +54 -0
- package/dist/esm/util/access-control.d.ts.map +1 -0
- package/dist/esm/util/access-control.js +100 -0
- package/dist/esm/util/access-control.js.map +6 -0
- package/dist/esm/util/binding.d.ts +54 -0
- package/dist/esm/util/binding.d.ts.map +1 -0
- package/dist/esm/util/binding.js +113 -0
- package/dist/esm/util/binding.js.map +6 -0
- package/dist/esm/util/endpoints.d.ts +9 -0
- package/dist/esm/util/endpoints.d.ts.map +1 -0
- package/dist/esm/util/endpoints.js +18 -0
- package/dist/esm/util/endpoints.js.map +6 -0
- package/dist/esm/util/node-name.d.ts +12 -0
- package/dist/esm/util/node-name.d.ts.map +1 -0
- package/dist/esm/util/node-name.js +15 -0
- package/dist/esm/util/node-name.js.map +6 -0
- package/dist/web/js/{attribute-write-dialog-W7xpCE2E.js → attribute-write-dialog-CqqdRniU.js} +1 -1
- package/dist/web/js/{command-invoke-dialog-BAqAAdJw.js → command-invoke-dialog-BuvBOrdC.js} +1 -1
- package/dist/web/js/{commission-node-dialog-BTzCGgdy.js → commission-node-dialog-nVZp3go0.js} +5 -5
- package/dist/web/js/{commission-node-existing-B2M2hyDh.js → commission-node-existing-Cx3Ahk2t.js} +2 -2
- package/dist/web/js/{commission-node-thread-djdz2dXW.js → commission-node-thread-CI8mWWPs.js} +2 -2
- package/dist/web/js/{commission-node-wifi-DxAYNS1A.js → commission-node-wifi-lUX4LK2R.js} +2 -2
- package/dist/web/js/{dialog-box-tHvPVxDN.js → dialog-box-bAdbnf-T.js} +1 -1
- package/dist/web/js/{fire_event-BleYfTLc.js → fire_event-tWhqPfdz.js} +1 -1
- package/dist/web/js/main.js +1 -1
- package/dist/web/js/{matter-dashboard-app-CVi_GDky.js → matter-dashboard-app-CONA_608.js} +1485 -390
- package/dist/web/js/node-acl-add-dialog-DlR-sF-b.js +320 -0
- package/dist/web/js/node-binding-dialog-DhnX_86M.js +267 -0
- package/dist/web/js/{node-label-dialog-DVZSjsXU.js → node-label-dialog-T3nPG-Qy.js} +1 -1
- package/dist/web/js/{settings-dialog-BG5MgZcO.js → settings-dialog-DrHzJtsi.js} +1 -1
- package/package.json +4 -4
- package/src/components/dialogs/acl/acl-actions.ts +71 -0
- package/src/components/dialogs/acl/model.ts +18 -17
- package/src/components/dialogs/acl/node-acl-add-dialog.ts +350 -0
- package/src/components/dialogs/acl/show-node-acl-add-dialog.ts +14 -0
- package/src/components/dialogs/binding/binding-actions.ts +201 -0
- package/src/components/dialogs/binding/model.ts +11 -4
- package/src/components/dialogs/binding/node-binding-dialog.ts +221 -399
- package/src/pages/cluster-commands/clusters/access-control-commands.ts +407 -0
- package/src/pages/cluster-commands/clusters/binding-commands.ts +273 -0
- package/src/pages/cluster-commands/index.ts +2 -0
- package/src/pages/components/node-details.ts +2 -21
- package/src/pages/components/server-details.ts +3 -3
- package/src/pages/matter-cluster-view.ts +4 -1
- package/src/pages/matter-endpoint-view.ts +16 -10
- package/src/pages/matter-node-view.ts +2 -2
- package/src/pages/network/network-utils.ts +1 -18
- package/src/util/access-control.ts +135 -0
- package/src/util/binding.ts +182 -0
- package/src/util/endpoints.ts +17 -0
- package/src/util/node-name.ts +18 -0
- 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,
|
|
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 "
|
|
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">
|
|
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">
|
|
49
|
+
<div class="left">FabricId: </div>${this.client.serverInfo.fabric_id}
|
|
50
50
|
</div>
|
|
51
51
|
<div slot="supporting-text">
|
|
52
|
-
<div class="left">
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
+
}
|