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` + +
+
Binding
+
+
+
+ + ${Object.values(bindings).map( + (entry, index) => html` + +
+
node:${entry["node"]}
+
endpoint:${entry["endpoint"]}
+ ${entry["cluster"] ? html`
cluster:${entry["cluster"]}
` : nothing} +
+
+ this.deleteBindingHandler(index)} + >delete + + `, + )} + +
+
target
+ + + +
+
+
+
+ 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)."""