diff --git a/dashboard/note.md b/dashboard/note.md
new file mode 100644
index 00000000..12a6d1b2
--- /dev/null
+++ b/dashboard/note.md
@@ -0,0 +1,12 @@
+```
+
+
${"privilege:" + entry.privilege}
+
${"authMode:" + entry.authMode}
+
${"fabricIndex:" + entry.fabricIndex}
+
+ subjects:${JSON.stringify(entry.subjects)}
+ targets:${JSON.stringify(entry.targets)}
+
+```
diff --git a/dashboard/src/client/client.ts b/dashboard/src/client/client.ts
index 25625f68..19d0f5f6 100644
--- a/dashboard/src/client/client.ts
+++ b/dashboard/src/client/client.ts
@@ -143,6 +143,21 @@ export class MatterClient {
await this.sendCommand("update_node", 10, { node_id: nodeId, software_version: softwareVersion });
}
+ async setACLEntry(nodeId: number, entry: any) {
+ return await this.sendCommand("set_acl_entry", 0, {
+ node_id: nodeId,
+ entry: entry,
+ });
+ }
+
+ async setNodeBinding(nodeId: number, endpoint: number, bindings: any) {
+ return await this.sendCommand("set_node_binding", 0, {
+ node_id: nodeId,
+ endpoint: endpoint,
+ bindings: bindings,
+ });
+ }
+
async sendCommand(
command: T,
require_schema: number | undefined = undefined,
diff --git a/dashboard/src/client/models/model.ts b/dashboard/src/client/models/model.ts
index 2c04fe66..bb0ef517 100644
--- a/dashboard/src/client/models/model.ts
+++ b/dashboard/src/client/models/model.ts
@@ -105,6 +105,14 @@ export interface APICommands {
requestArgs: {};
response: {};
};
+ set_acl_entry: {
+ requestArgs: {};
+ response: {};
+ };
+ set_node_binding: {
+ requestArgs: {};
+ response: {};
+ };
}
export interface CommandMessage {
diff --git a/dashboard/src/components/dialogs/acl/model.ts b/dashboard/src/components/dialogs/acl/model.ts
new file mode 100644
index 00000000..88fa9816
--- /dev/null
+++ b/dashboard/src/components/dialogs/acl/model.ts
@@ -0,0 +1,109 @@
+
+export type AccessControlEntryRawInput = {
+ "1": number;
+ "2": number;
+ "3": number[];
+ "4": null;
+ "254": number;
+};
+
+export type AccessControlTargetStruct = {
+ cluster: number | undefined;
+ endpoint: number | undefined;
+ deviceType: number | undefined;
+};
+
+export type AccessControlEntryStruct = {
+ privilege: number;
+ authMode: number;
+ subjects: number[];
+ targets: AccessControlTargetStruct[] | undefined;
+ fabricIndex: number;
+};
+
+export class AccessControlTargetTransformer {
+ private static readonly KEY_MAPPING: {
+ [inputKey: string]: keyof AccessControlTargetStruct;
+ } = {
+ "0": "cluster",
+ "1": "endpoint",
+ "2": "deviceType",
+ };
+
+ public static transform(input: any): AccessControlTargetStruct {
+ if (!input || typeof input !== "object") {
+ throw new Error("Invalid input: expected an object");
+ }
+
+ const result: Partial = {};
+ const keyMapping = AccessControlTargetTransformer.KEY_MAPPING;
+
+ for (const key in input) {
+ if (key in keyMapping) {
+ const mappedKey = keyMapping[key];
+ if (mappedKey) {
+ const value = input[key];
+ if (value === undefined) continue;
+ result[mappedKey] = value;
+ }
+ }
+ }
+ return result as AccessControlTargetStruct;
+ }
+}
+
+export class AccessControlEntryDataTransformer {
+ private static readonly KEY_MAPPING: {
+ [inputKey: string]: keyof AccessControlEntryStruct;
+ } = {
+ "1": "privilege",
+ "2": "authMode",
+ "3": "subjects",
+ "4": "targets",
+ "254": "fabricIndex",
+ };
+
+ public static transform(input: any): AccessControlEntryStruct {
+ if (!input || typeof input !== "object") {
+ throw new Error("Invalid input: expected an object");
+ }
+
+ const result: Partial = {};
+ const keyMapping = AccessControlEntryDataTransformer.KEY_MAPPING;
+
+ for (const key in input) {
+ if (key in keyMapping) {
+ const mappedKey = keyMapping[key];
+ if (mappedKey) {
+ const value = input[key];
+ if (value === undefined) continue;
+ if (mappedKey === "subjects") {
+ result[mappedKey] = Array.isArray(value) ? value : undefined;
+ } else if (mappedKey === "targets") {
+ if (Array.isArray(value)) {
+ const _targets = Object.values(value).map((val) =>
+ AccessControlTargetTransformer.transform(val),
+ );
+ result[mappedKey] = _targets;
+ } else {
+ result[mappedKey] = undefined;
+ }
+ } else {
+ result[mappedKey] = value;
+ }
+ }
+ }
+ }
+
+ if (
+ result.privilege === undefined ||
+ result.authMode === undefined ||
+ result.subjects === undefined ||
+ result.fabricIndex === undefined
+ ) {
+ throw new Error("Missing required fields in AccessControlEntryStruct");
+ }
+
+ return result as AccessControlEntryStruct;
+ }
+}
diff --git a/dashboard/src/components/dialogs/binding/model.ts b/dashboard/src/components/dialogs/binding/model.ts
new file mode 100644
index 00000000..e1b224d3
--- /dev/null
+++ b/dashboard/src/components/dialogs/binding/model.ts
@@ -0,0 +1,61 @@
+export type InputType = {
+ [key: string]: number | number[] | undefined;
+};
+
+export interface BindingEntryStruct {
+ node: number;
+ group: number | undefined;
+ endpoint: number;
+ cluster: number | undefined;
+ fabricIndex: number | undefined;
+}
+
+export class BindingEntryDataTransformer {
+ private static readonly KEY_MAPPING: {
+ [inputKey: string]: keyof BindingEntryStruct;
+ } = {
+ "1": "node",
+ "3": "endpoint",
+ "4": "cluster",
+ "254": "fabricIndex",
+ };
+
+ public static transform(input: any): BindingEntryStruct {
+ if (!input || typeof input !== "object") {
+ throw new Error("Invalid input: expected an object");
+ }
+
+ const result: Partial = {};
+ const keyMapping = BindingEntryDataTransformer.KEY_MAPPING;
+
+ for (const key in input) {
+ if (key in keyMapping) {
+ const mappedKey = keyMapping[key];
+ if (mappedKey) {
+ const value = input[key];
+ if (value === undefined) {
+ continue;
+ }
+ if (mappedKey === "fabricIndex") {
+ result[mappedKey] = value === undefined ? undefined : Number(value);
+ } else if (mappedKey === "node" || mappedKey === "endpoint") {
+ result[mappedKey] = Number(value);
+ } else {
+ result[mappedKey] = value as BindingEntryStruct[typeof mappedKey];
+ }
+ }
+ }
+ }
+
+ // Validate required fields
+ if (
+ result.node === undefined ||
+ result.endpoint === undefined ||
+ result.fabricIndex === undefined
+ ) {
+ throw new Error("Missing required fields in BindingEntryStruct");
+ }
+
+ return result as BindingEntryStruct;
+ }
+}
diff --git a/dashboard/src/components/dialogs/binding/node-binding-dialog.ts b/dashboard/src/components/dialogs/binding/node-binding-dialog.ts
new file mode 100644
index 00000000..2d7dc6db
--- /dev/null
+++ b/dashboard/src/components/dialogs/binding/node-binding-dialog.ts
@@ -0,0 +1,356 @@
+import "@material/web/button/text-button";
+import "@material/web/dialog/dialog";
+import "@material/web/list/list";
+import "@material/web/list/list-item";
+import "../../../components/ha-svg-icon";
+import "@material/web/textfield/outlined-text-field";
+import type { MdOutlinedTextField } from "@material/web/textfield/outlined-text-field";
+
+import { html, LitElement, css, nothing } from "lit";
+import { customElement, property, query } from "lit/decorators.js";
+import { MatterNode } from "../../../client/models/node";
+import { preventDefault } from "../../../util/prevent_default";
+import { MatterClient } from "../../../client/client";
+import {
+ InputType,
+ BindingEntryStruct,
+ BindingEntryDataTransformer,
+} from "./model";
+
+import {
+ AccessControlEntryDataTransformer,
+ AccessControlEntryStruct,
+ AccessControlTargetStruct,
+} from "../acl/model";
+
+import { consume } from "@lit/context";
+import { clientContext } from "../../../client/client-context";
+
+@customElement("node-binding-dialog")
+export class NodeBindingDialog extends LitElement {
+ @consume({ context: clientContext, subscribe: true })
+ @property({ attribute: false })
+ public client!: MatterClient;
+
+ @property()
+ public node?: MatterNode;
+
+ @property({ attribute: false })
+ endpoint!: number;
+
+ @query("md-outlined-text-field[label='node id']")
+ private _targetNodeId!: MdOutlinedTextField;
+
+ @query("md-outlined-text-field[label='endpoint']")
+ private _targetEndpoint!: MdOutlinedTextField;
+
+ @query("md-outlined-text-field[label='cluster']")
+ private _targetCluster!: MdOutlinedTextField;
+
+ private fetchBindingEntry(): BindingEntryStruct[] {
+ const bindings_raw: [] = this.node!.attributes[this.endpoint + "/30/0"];
+ return Object.values(bindings_raw).map((value) =>
+ BindingEntryDataTransformer.transform(value),
+ );
+ }
+
+ private fetchACLEntry(targetNodeId: number): AccessControlEntryStruct[] {
+ const acl_cluster_raw: [InputType] =
+ this.client.nodes[targetNodeId].attributes["0/31/0"];
+
+ return Object.values(acl_cluster_raw).map((value: InputType) =>
+ AccessControlEntryDataTransformer.transform(value),
+ );
+ }
+
+ private async deleteBindingHandler(index: number): Promise {
+ const rawBindings = this.fetchBindingEntry();
+ try {
+ const targetNodeId = rawBindings[index].node;
+ await this.removeNodeAtACLEntry(
+ this.node!.node_id,
+ this.endpoint,
+ targetNodeId,
+ );
+ const updatedBindings = this.removeBindingAtIndex(rawBindings, index);
+ await this.syncBindingUpdates(updatedBindings, index);
+ } catch (error) {
+ this.handleBindingDeletionError(error);
+ }
+ }
+
+ private async removeNodeAtACLEntry(
+ sourceNodeId: number,
+ sourceEndpoint: number,
+ targetNodeId: number,
+ ): Promise {
+ const aclEntries = this.fetchACLEntry(targetNodeId);
+
+ const updatedACLEntries = aclEntries
+ .map((entry) =>
+ this.removeEntryAtACL(sourceNodeId, sourceEndpoint, entry),
+ )
+ .filter((entry): entry is Exclude => entry !== null);
+
+ console.log(updatedACLEntries);
+ await this.client.setACLEntry(targetNodeId, updatedACLEntries);
+ }
+
+ private removeEntryAtACL(
+ nodeId: number,
+ sourceEndpoint: number,
+ entry: AccessControlEntryStruct,
+ ): AccessControlEntryStruct | null {
+ const hasSubject = entry.subjects!.includes(nodeId);
+
+ if (!hasSubject) return entry;
+
+ const hasTarget = entry.targets!.filter(
+ (item) => item.endpoint === sourceEndpoint,
+ );
+ return hasTarget.length > 0 ? null : entry;
+ }
+
+ private removeBindingAtIndex(
+ bindings: BindingEntryStruct[],
+ index: number,
+ ): BindingEntryStruct[] {
+ return [...bindings.slice(0, index), ...bindings.slice(index + 1)];
+ }
+
+ private async syncBindingUpdates(
+ updatedBindings: BindingEntryStruct[],
+ index: number,
+ ): Promise {
+ await this.client.setNodeBinding(
+ this.node!.node_id,
+ this.endpoint,
+ updatedBindings,
+ );
+
+ const attributePath = `${this.endpoint}/30/0`;
+ const updatedAttributes = {
+ ...this.node!.attributes,
+ [attributePath]: this.removeBindingAtIndex(
+ this.node!.attributes[attributePath],
+ index,
+ ),
+ };
+
+ this.node!.attributes = updatedAttributes;
+ this.requestUpdate();
+ }
+
+ private handleBindingDeletionError(error: unknown): void {
+ const errorMessage = error instanceof Error ? error.message : String(error);
+ console.error(`Binding deletion failed: ${errorMessage}`);
+ }
+
+ private async _updateEntry(
+ targetId: number,
+ path: string,
+ entry: T,
+ transformFn: (value: InputType) => T,
+ updateFn: (targetId: number, entries: T[]) => Promise,
+ ) {
+ try {
+ const rawEntries: [InputType] =
+ this.client.nodes[targetId].attributes[path];
+ const entries = Object.values(rawEntries).map(transformFn);
+ entries.push(entry);
+ return await updateFn(targetId, entries);
+ } catch (err) {
+ console.log(err);
+ }
+ }
+
+ private async add_target_acl(
+ targetNodeId: number,
+ entry: AccessControlEntryStruct,
+ ) {
+ try {
+ const result = (await this._updateEntry(
+ targetNodeId,
+ "0/31/0",
+ entry,
+ AccessControlEntryDataTransformer.transform,
+ this.client.setACLEntry.bind(this.client),
+ )) as { [key: string]: { Status: number } };
+ return result["0"].Status === 0;
+ } catch (err) {
+ console.error("add acl error:", err);
+ return false;
+ }
+ }
+
+ private async add_bindings(
+ endpoint: number,
+ bindingEntry: BindingEntryStruct,
+ ) {
+ const bindings = this.fetchBindingEntry();
+ bindings.push(bindingEntry);
+ try {
+ const result = (await this.client.setNodeBinding(
+ this.node!.node_id,
+ endpoint,
+ bindings,
+ )) as { [key: string]: { Status: number } };
+ return result["0"].Status === 0;
+ } catch (err) {
+ console.log("add bindings error:", err);
+ return false;
+ }
+ }
+
+ async addBindingHandler() {
+ const targetNodeId = parseInt(this._targetNodeId.value, 10);
+ const targetEndpoint = parseInt(this._targetEndpoint.value, 10);
+ const targetCluster = parseInt(this._targetCluster.value, 10);
+
+ if (isNaN(targetNodeId) || targetNodeId <= 0) {
+ alert("Please enter a valid target node ID");
+ return;
+ }
+ if (isNaN(targetEndpoint) || targetEndpoint < 0) {
+ alert("Please enter a valid target endpoint");
+ return;
+ }
+ if (isNaN(targetCluster) || targetCluster < 0) {
+ alert("Please enter a valid target endpoint");
+ return;
+ }
+
+ const targets: AccessControlTargetStruct = {
+ endpoint: targetEndpoint,
+ cluster: targetCluster,
+ deviceType: undefined,
+ };
+
+ const acl_entry: AccessControlEntryStruct = {
+ privilege: 5,
+ authMode: 2,
+ subjects: [this.node!.node_id],
+ targets: [targets],
+ fabricIndex: this.client.connection.serverInfo!.fabric_id,
+ };
+ const result_acl = await this.add_target_acl(targetNodeId, acl_entry);
+ if (!result_acl) return;
+
+ const endpoint = this.endpoint;
+ const bindingEntry: BindingEntryStruct = {
+ node: targetNodeId,
+ endpoint: targetEndpoint,
+ group: undefined,
+ cluster: targetCluster,
+ fabricIndex: this.client.connection.serverInfo!.fabric_id,
+ };
+
+ const result_binding = await this.add_bindings(endpoint, bindingEntry);
+
+ if (result_binding) {
+ this._targetNodeId.value = "";
+ this._targetEndpoint.value = "";
+ this._targetCluster.value = "";
+ this.requestUpdate();
+ }
+ }
+
+ private _close() {
+ this.shadowRoot!.querySelector("md-dialog")!.close();
+ }
+
+ private _handleClosed() {
+ this.parentNode!.removeChild(this);
+ }
+
+ protected render() {
+ const bindings = Object.values(
+ this.node!.attributes[this.endpoint + "/30/0"],
+ ).map((entry) => BindingEntryDataTransformer.transform(entry));
+
+ return html`
+
+
+
+
+
+ ${Object.values(bindings).map(
+ (entry, index) => html`
+
+
+
node:${entry["node"]}
+
endpoint:${entry["endpoint"]}
+ ${entry["cluster"] ? html`
cluster:${entry["cluster"]}
` : nothing}
+
+
+
this.deleteBindingHandler(index)}
+ >delete
+
+ `,
+ )}
+
+
+
+
+
+ Add
+ Cancel
+
+
+ `;
+ }
+
+ static styles = css`
+ .inline-group {
+ display: flex;
+ border: 2px solid #673ab7;
+ padding: 1px;
+ border-radius: 8px;
+ position: relative;
+ margin: 8px;
+ }
+
+ .target-item {
+ display: inline-block;
+ padding: 20px 10px 10px 10px;
+ border-radius: 4px;
+ vertical-align: middle;
+ min-width: 80px;
+ text-align: center;
+ }
+
+ .group-label {
+ position: absolute;
+ left: 15px;
+ top: -12px;
+ background: #673ab7;
+ color: white;
+ padding: 3px 15px;
+ border-radius: 4px;
+ }
+ `;
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "node-binding-dialog": NodeBindingDialog;
+ }
+}
diff --git a/dashboard/src/components/dialogs/binding/show-node-binding-dialog.ts b/dashboard/src/components/dialogs/binding/show-node-binding-dialog.ts
new file mode 100644
index 00000000..22351d8b
--- /dev/null
+++ b/dashboard/src/components/dialogs/binding/show-node-binding-dialog.ts
@@ -0,0 +1,17 @@
+import { MatterClient } from "../../../client/client";
+import { MatterNode } from "../../../client/models/node";
+
+export const showNodeBindingDialog = async (
+ client: MatterClient,
+ node: MatterNode,
+ endpoint: number,
+) => {
+ await import("./node-binding-dialog");
+ const dialog = document.createElement("node-binding-dialog");
+ dialog.client = client;
+ dialog.node = node;
+ dialog.endpoint = endpoint;
+ document
+ .querySelector("matter-dashboard-app")
+ ?.renderRoot.appendChild(dialog);
+};
diff --git a/dashboard/src/pages/components/context.ts b/dashboard/src/pages/components/context.ts
new file mode 100644
index 00000000..f0a0c7d6
--- /dev/null
+++ b/dashboard/src/pages/components/context.ts
@@ -0,0 +1,5 @@
+
+import { createContext } from "@lit/context";
+
+// export const bindingContext = createContext
("");
+export const bindingContext = createContext("binding");
diff --git a/dashboard/src/pages/components/node-details.ts b/dashboard/src/pages/components/node-details.ts
index 437c9aeb..8cea5ce0 100644
--- a/dashboard/src/pages/components/node-details.ts
+++ b/dashboard/src/pages/components/node-details.ts
@@ -5,7 +5,15 @@ import "@material/web/divider/divider";
import "@material/web/iconbutton/icon-button";
import "@material/web/list/list";
import "@material/web/list/list-item";
-import { mdiChatProcessing, mdiShareVariant, mdiTrashCan, mdiUpdate } from "@mdi/js";
+import {
+ mdiChatProcessing,
+ mdiShareVariant,
+ mdiTrashCan,
+ mdiUpdate,
+ mdiLink,
+} from "@mdi/js";
+
+import { consume } from "@lit/context";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { MatterClient } from "../../client/client";
@@ -17,6 +25,8 @@ import {
} from "../../components/dialog-box/show-dialog-box";
import "../../components/ha-svg-icon";
import { getEndpointDeviceTypes } from "../matter-endpoint-view";
+import { bindingContext } from "./context";
+import { showNodeBindingDialog } from "../../components/dialogs/binding/show-node-binding-dialog";
function getNodeDeviceTypes(node: MatterNode): DeviceType[] {
const uniqueEndpoints = new Set(
@@ -40,8 +50,15 @@ export class NodeDetails extends LitElement {
@state()
private _updateInitiated: boolean = false;
+ @consume({ context: bindingContext })
+ @property({ attribute: false })
+ endpoint!: number;
+
protected render() {
if (!this.node) return html``;
+
+ const bindings = this.node.attributes[this.endpoint + "/30/0"];
+
return html`
@@ -90,6 +107,15 @@ export class NodeDetails extends LitElement {
Update in progress (${this.node.updateStateProgress || 0}%)`
: html`Update`}
+ ${bindings
+ ? html`
+
+ Binding
+
+
+ `
+ : nothing}
+
Share
Remove
@@ -144,6 +170,14 @@ export class NodeDetails extends LitElement {
}
}
+ private async _binding() {
+ try {
+ showNodeBindingDialog(this.client!, this.node!, this.endpoint!);
+ } catch (err: any) {
+ console.log(err);
+ }
+ }
+
private async _searchUpdate() {
const nodeUpdate = await this.client.checkNodeUpdate(this.node!.node_id);
if (!nodeUpdate) {
diff --git a/dashboard/src/pages/matter-cluster-view.ts b/dashboard/src/pages/matter-cluster-view.ts
index f604531a..3ca3ea4a 100644
--- a/dashboard/src/pages/matter-cluster-view.ts
+++ b/dashboard/src/pages/matter-cluster-view.ts
@@ -9,6 +9,9 @@ import { clusters } from "../client/models/descriptions";
import { MatterNode } from "../client/models/node";
import { showAlertDialog } from "../components/dialog-box/show-dialog-box";
import "../components/ha-svg-icon";
+import "../pages/components/node-details";
+import { provide } from "@lit/context";
+import { bindingContext } from "./components/context";
declare global {
interface HTMLElementTagNameMap {
@@ -37,8 +40,9 @@ class MatterClusterView extends LitElement {
@property()
public node?: MatterNode;
+ @provide({ context: bindingContext })
@property()
- public endpoint?: number;
+ public endpoint!: number;
@property()
public cluster?: number;
diff --git a/matter_server/common/models.py b/matter_server/common/models.py
index 56d3fb1d..fb897502 100644
--- a/matter_server/common/models.py
+++ b/matter_server/common/models.py
@@ -51,6 +51,8 @@ class APICommand(str, Enum):
CHECK_NODE_UPDATE = "check_node_update"
UPDATE_NODE = "update_node"
SET_DEFAULT_FABRIC_LABEL = "set_default_fabric_label"
+ SET_ACL_ENTRY = "set_acl_entry"
+ SET_NODE_BINDING = "set_node_binding"
EventCallBackType = Callable[[EventType, Any], None]
diff --git a/matter_server/server/device_controller.py b/matter_server/server/device_controller.py
index 0e344c84..492c36b6 100644
--- a/matter_server/server/device_controller.py
+++ b/matter_server/server/device_controller.py
@@ -20,7 +20,7 @@
from chip.ChipDeviceCtrl import ChipDeviceController
from chip.clusters import Attribute, Objects as Clusters
-from chip.clusters.Attribute import ValueDecodeFailure
+from chip.clusters.Attribute import AttributeWriteResult, ValueDecodeFailure
from chip.clusters.ClusterObjects import ALL_ATTRIBUTES, ALL_CLUSTERS, Cluster
from chip.discovery import DiscoveryType
from chip.exceptions import ChipStackError
@@ -862,6 +862,29 @@ async def remove_node(self, node_id: int) -> None:
except ChipStackError as err:
LOGGER.warning("Removing current fabric from device failed: %s", err)
+ @api_command(APICommand.SET_ACL_ENTRY)
+ async def set_acl_entry(
+ self,
+ node_id: int,
+ entry: list[Clusters.AccessControl.Structs.AccessControlEntryStruct],
+ ) -> list[AttributeWriteResult] | None:
+ """Set acl entry"""
+ return await self._chip_device_controller.write_attribute(
+ node_id, [(0, Clusters.AccessControl.Attributes.Acl(entry))]
+ )
+
+ @api_command(APICommand.SET_NODE_BINDING)
+ async def set_node_binding(
+ self,
+ node_id: int,
+ endpoint: int,
+ bindings: list[Clusters.Binding.Structs.TargetStruct],
+ ) -> list[AttributeWriteResult] | None:
+ """Set node binding"""
+ return await self._chip_device_controller.write_attribute(
+ node_id, [(endpoint, Clusters.Binding.Attributes.Binding(bindings))]
+ )
+
@api_command(APICommand.PING_NODE)
async def ping_node(self, node_id: int, attempts: int = 1) -> NodePingResult:
"""Ping node on the currently known IP-address(es)."""