-
Notifications
You must be signed in to change notification settings - Fork 46
feat: Matter multicast group support (controller + WS API) #756
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
ad004d0
0b46ada
067ca5c
b1f7695
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -295,6 +295,63 @@ export interface APICommands { | |
| requestArgs: { console_loglevel?: SettableLogLevelString; file_loglevel?: SettableLogLevelString }; | ||
| response: LogLevelResponse; | ||
| }; | ||
| /** | ||
| * Matter multicast group management (`create_group` … `reconcile_group`). | ||
| * | ||
| * OHF Matter Server extension — NOT part of the Python Matter Server API, which has | ||
| * no group commands and tracks the gap upstream in home-assistant-libs/python-matter-server#1071. | ||
| * Group multicast send (`send_group_command`) cannot be expressed via the unicast-only | ||
| * `device_command`, and installing the operational group key on the controller's own | ||
| * fabric is inherently server-side, so these are first-class commands rather than | ||
| * client-driven `device_command` sequences. Clients can feature-detect via | ||
| * `ServerInfoMessage.groups_supported`. Command names may change to track #1071. | ||
| */ | ||
| create_group: { | ||
| requestArgs: { | ||
| name: string; | ||
| /** Optional explicit application group id (0x0001..0xFEFF). Auto-allocated when omitted. */ | ||
| group_id?: number; | ||
| /** | ||
| * Optional ACL targets scoping the group's access on each member (least | ||
| * privilege). When omitted the group is granted Operate on all clusters. | ||
| */ | ||
| acl_targets?: AccessControlTarget[]; | ||
| }; | ||
| response: MatterGroupData; | ||
| }; | ||
| delete_group: { | ||
| requestArgs: { group_id: number }; | ||
| response: null; | ||
| }; | ||
| get_groups: { | ||
| requestArgs: Record<string, never>; | ||
| response: MatterGroupData[]; | ||
| }; | ||
| add_group_member: { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. lets change to "join_group" because this way it is also called in matter and will later on be with new Groupcast coming |
||
| requestArgs: { group_id: number; node_id: number | bigint; endpoint_id: number }; | ||
| response: MatterGroupData; | ||
| }; | ||
| remove_group_member: { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. And here "leave_group" |
||
| requestArgs: { group_id: number; node_id: number | bigint; endpoint_id: number }; | ||
| response: MatterGroupData; | ||
| }; | ||
| send_group_command: { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. formally not needed because each group has it's own Matter node Id which contains the groupId, so I would favor to not have a special websocket command to send a command but just use the normal one with a group node id provided There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I agree with this comment. Using Usability is worth considering when UI elements are implemented. It's relatively straightforward to convert a hexadecimal group ID into a node ID, but somewhat more painful to do with decimal IDs. |
||
| requestArgs: { | ||
| group_id: number; | ||
| cluster_id: number; | ||
| command_name: string; | ||
| payload?: unknown; | ||
| }; | ||
| response: null; | ||
| }; | ||
| reconcile_group: { | ||
| requestArgs: { | ||
| group_id: number; | ||
| /** When true, re-provision members whose on-device membership has drifted. */ | ||
| repair?: boolean; | ||
| }; | ||
| response: GroupReconciliation; | ||
| }; | ||
| } | ||
|
|
||
| /** Utility type to extract request args for a command */ | ||
|
|
@@ -335,6 +392,55 @@ export interface AccessControlTarget { | |
| device_type: number | null; | ||
| } | ||
|
|
||
| /** A single (node, endpoint) member of a Matter group. */ | ||
| export interface MatterGroupMember { | ||
| node_id: number | bigint; | ||
| endpoint_id: number; | ||
| } | ||
|
|
||
| /** | ||
| * A Matter multicast group managed by the controller. | ||
| * | ||
| * The controller owns the operational group key for the group on its own fabric | ||
| * and provisions the same key + membership onto each member device, so a single | ||
| * `send_group_command` reaches every member via IPv6 group multicast. | ||
| * | ||
| * The operational epoch key itself is sensitive and is never exposed over the API. | ||
| */ | ||
| export interface MatterGroupData { | ||
| /** Matter group id (1..0xFFFF). */ | ||
| group_id: number; | ||
| /** Human-readable group name (<=16 chars, per the Groups cluster). */ | ||
| name: string; | ||
| /** Group key set id binding the group to its operational key on the fabric. */ | ||
| group_key_set_id: number; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why this is needed for externals? |
||
| /** Devices that have been provisioned into the group. */ | ||
| members: MatterGroupMember[]; | ||
| /** | ||
| * ACL targets scoping the group's access on each member. When absent the group | ||
| * is granted Operate on all clusters/endpoints. | ||
| */ | ||
| acl_targets?: AccessControlTarget[]; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. see above |
||
| } | ||
|
|
||
| /** Per-member result of reconciling a group's registry against on-device state. */ | ||
| export interface GroupMemberReconciliation { | ||
| node_id: number | bigint; | ||
| endpoint_id: number; | ||
| /** Whether the node was reachable for inspection. */ | ||
| reachable: boolean; | ||
| /** Whether the membership is present in the device's GroupKeyManagement.GroupTable. */ | ||
| on_device: boolean; | ||
| /** Whether this member was re-provisioned during this reconcile (repair mode). */ | ||
| repaired: boolean; | ||
| } | ||
|
|
||
| /** Result of `reconcile_group`: registry membership checked against device truth. */ | ||
| export interface GroupReconciliation { | ||
| group_id: number; | ||
| members: GroupMemberReconciliation[]; | ||
| } | ||
|
|
||
| /** Binding target for set_node_binding command */ | ||
| export interface BindingTarget { | ||
| node: number | bigint | null; | ||
|
|
@@ -384,6 +490,8 @@ export interface ServerInfoMessage { | |
| ble_proxy_enabled?: boolean; | ||
| /** The controller's own operational (CASE) node id. Note: Only available with OHF Matter Server, not Python Matter Server. */ | ||
| controller_node_id?: number | bigint; | ||
| /** True when the server supports Matter multicast group commands (`create_group`, `send_group_command`, …). OHF Matter Server extension; absent on Python Matter Server. */ | ||
| groups_supported?: boolean; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is a feature additional that usually means we increase the websocket schema version and via this users know which kind of protocol it is and what is supported. So this would be the right way to do that. And because this is just "adding commands" the server can easiely support the current and this next version because they are not really breaking. |
||
| } | ||
|
|
||
| /** WebSocket event types and their data payloads */ | ||
|
|
@@ -418,6 +526,15 @@ export interface APIEvents { | |
| webrtc_callback: { | ||
| data: WebRtcCallbackData; | ||
| }; | ||
| group_added: { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. whats the use case for the events? |
||
| data: MatterGroupData; | ||
| }; | ||
| group_updated: { | ||
| data: MatterGroupData; | ||
| }; | ||
| group_removed: { | ||
| data: number; | ||
| }; | ||
| } | ||
|
|
||
| /** All known event type names */ | ||
|
|
@@ -463,6 +580,18 @@ interface ServerEventWebRtcCallback { | |
| event: "webrtc_callback"; | ||
| data: WebRtcCallbackData; | ||
| } | ||
| interface ServerEventGroupAdded { | ||
| event: "group_added"; | ||
| data: MatterGroupData; | ||
| } | ||
| interface ServerEventGroupUpdated { | ||
| event: "group_updated"; | ||
| data: MatterGroupData; | ||
| } | ||
| interface ServerEventGroupRemoved { | ||
| event: "group_removed"; | ||
| data: number; | ||
| } | ||
|
|
||
| export type EventMessage = | ||
| | ServerEventNodeAdded | ||
|
|
@@ -474,7 +603,10 @@ export type EventMessage = | |
| | ServerEventEndpointAdded | ||
| | ServerEventEndpointRemoved | ||
| | ServerEventInfoUpdated | ||
| | ServerEventWebRtcCallback; | ||
| | ServerEventWebRtcCallback | ||
| | ServerEventGroupAdded | ||
| | ServerEventGroupUpdated | ||
| | ServerEventGroupRemoved; | ||
|
|
||
| export interface ResultMessageBase { | ||
| message_id: string; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -87,6 +87,7 @@ import { | |
| AccessControlTarget, | ||
| AttributeWriteResult, | ||
| BindingTarget, | ||
| MatterGroupData, | ||
| MatterSoftwareVersion, | ||
| NodePingResult, | ||
| ServerError, | ||
|
|
@@ -96,6 +97,8 @@ import { formatNodeId } from "../util/formatNodeId.js"; | |
| import { pingIp } from "../util/network.js"; | ||
| import { WebRtcTransportRequestorServer } from "./behaviors/WebRtcTransportRequestorServer.js"; | ||
| import { CustomClusterPoller } from "./CustomClusterPoller.js"; | ||
| import { GroupManager } from "./GroupManager.js"; | ||
| import { GroupRegistry } from "./GroupRegistry.js"; | ||
| import { Nodes } from "./Nodes.js"; | ||
| import { attachWebRtcCallbackBridge } from "./WebRtcCallbackBridge.js"; | ||
|
|
||
|
|
@@ -173,8 +176,13 @@ export class ControllerCommandHandler { | |
| nodeEndpointAdded: new Observable<[nodeId: NodeId, endpointId: EndpointNumber]>(), | ||
| nodeEndpointRemoved: new Observable<[nodeId: NodeId, endpointId: EndpointNumber]>(), | ||
| webRtcCallback: new Observable<[WebRtcCallbackData]>(), | ||
| groupAdded: new Observable<[MatterGroupData]>(), | ||
| groupUpdated: new Observable<[MatterGroupData]>(), | ||
| groupRemoved: new Observable<[groupId: number]>(), | ||
| }; | ||
| #peers?: PeerSet; | ||
| /** Multicast group manager; created lazily on start once the controller fabric exists. */ | ||
| #groupManager?: GroupManager; | ||
|
|
||
| constructor( | ||
| controllerInstance: CommissioningController, | ||
|
|
@@ -244,6 +252,26 @@ export class ControllerCommandHandler { | |
| logger.notice(`Matter Controller started`); | ||
| this.#peers = this.#controller.node.env.get(PeerSet); | ||
|
|
||
| // Initialize multicast group support: load persisted groups and re-install | ||
| // their operational key material onto the controller's own fabric. | ||
| const groupRegistry = await GroupRegistry.create(this.#controller.node.env); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why the groupregistry needs to be an entity out side here? Why the Groupmanager does not create the needed registry itself and uses internally? And with GroupManager.create() you also preve the special "restrore" call here externally |
||
| this.#groupManager = new GroupManager(this.#controller, groupRegistry, { | ||
| invokeNative: (nodeId, endpointId, clusterId, commandName, fields) => | ||
| this.#invokeNative(nodeId, endpointId, clusterId, commandName, fields), | ||
| writeNative: (nodeId, endpointId, clusterId, attributeName, value) => | ||
| this.#writeAttribute(nodeId, endpointId, clusterId, attributeName, value), | ||
| readNativeAttribute: (nodeId, endpointId, clusterId, attributeId) => | ||
| this.#readNativeAttribute(nodeId, endpointId, clusterId, attributeId), | ||
| currentFabricIndex: nodeId => this.#currentFabricIndex(nodeId), | ||
| isAvailable: nodeId => this.#nodes.isAvailable(nodeId), | ||
| }); | ||
| // Bridge group lifecycle events onto the always-present events object so the | ||
| // WebSocket layer can subscribe regardless of start ordering. | ||
| this.#groupManager.events.groupAdded.on(data => this.events.groupAdded.emit(data)); | ||
| this.#groupManager.events.groupUpdated.on(data => this.events.groupUpdated.emit(data)); | ||
| this.#groupManager.events.groupRemoved.on(groupId => this.events.groupRemoved.emit(groupId)); | ||
| await this.#groupManager.restore(); | ||
|
|
||
| if (this.#otaEnabled) { | ||
| // Subscribe to OTA provider events to track available updates | ||
| await this.#setupOtaEventHandlers(); | ||
|
|
@@ -764,6 +792,82 @@ export class ControllerCommandHandler { | |
| return { attributeId, clusterId, endpointId, status, clusterStatus }; | ||
| } | ||
|
|
||
| /** Multicast group manager. Available only after {@link start} has run. */ | ||
| get groups(): GroupManager { | ||
| if (this.#groupManager === undefined) { | ||
| throw ServerError.sdkStackError("Group manager not initialized (controller not started)"); | ||
| } | ||
| return this.#groupManager; | ||
| } | ||
|
|
||
| /** | ||
| * Invoke a command on a node with native (already Matter-shaped) fields, bypassing | ||
| * the WebSocket tag-based conversion. Used by {@link GroupManager} for provisioning. | ||
| */ | ||
| async #invokeNative( | ||
| nodeId: NodeId, | ||
| endpointId: EndpointNumber, | ||
| clusterId: ClusterId, | ||
| commandName: string, | ||
| fields: unknown, | ||
| ): Promise<unknown> { | ||
| const clusterEntry = ClusterMap[clusterId]; | ||
| if (!clusterEntry) { | ||
| throw ServerError.invalidArguments(`Cluster Id "${clusterId}" unknown`); | ||
| } | ||
| const cluster = ClusterType(clusterEntry.model) as Specifier.ClusterLike; | ||
| return await this.#invokeCommand(this.#nodes.get(nodeId).node, { | ||
| endpoint: endpointId, | ||
| cluster, | ||
| command: commandName, | ||
| fields, | ||
| }); | ||
| } | ||
|
|
||
| /** | ||
| * Read a single attribute from a node over the wire and return its raw native | ||
| * (un-converted) value. Used by {@link GroupManager} for read-modify-write of | ||
| * fabric-scoped lists (ACL, GroupKeyMap) so it never clobbers existing entries | ||
| * based on stale cache. Reads are fabric-filtered so only the current fabric's | ||
| * entries are returned and subsequently rewritten. | ||
| */ | ||
| async #readNativeAttribute( | ||
| nodeId: NodeId, | ||
| endpointId: EndpointNumber, | ||
| clusterId: ClusterId, | ||
| attributeId: number, | ||
| fabricFiltered = true, | ||
| ): Promise<unknown> { | ||
| const node = this.#nodes.get(nodeId); | ||
| const readRequest = { | ||
| ...Read({ | ||
| fabricFilter: fabricFiltered, | ||
| attributes: [ | ||
| { | ||
| endpointId, | ||
| clusterId, | ||
| attributeId: AttributeId(attributeId), | ||
| }, | ||
| ], | ||
| }), | ||
| includeKnownVersions: true, | ||
| }; | ||
|
|
||
| let value: unknown = undefined; | ||
| for await (const chunk of node.node.interaction.read(readRequest)) { | ||
| for await (const entry of chunk) { | ||
| if (entry.kind === "attr-value") { | ||
| value = entry.value; | ||
| } else if (entry.kind === "attr-status") { | ||
| logger.warn( | ||
| `Failed to read attribute ${endpointId}/${clusterId}/${attributeId}: status=${entry.status}`, | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| return value; | ||
| } | ||
|
|
||
| async #invokeCommand<const C extends Specifier.ClusterLike>( | ||
| node: ClientNode, | ||
| request: Invoke.ConcreteCommandRequest<C>, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
acl targets already include the endpoint and cluster? they not really belong into the group record unless you want to pre-prime a group for a dedicated use case which is problematic anyway because of the endpoint Id ... and would only work for clusters.
I see that the code uses this to add acls for the joined member but I do not really get the real idea of that.