Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 133 additions & 1 deletion packages/ws-client/src/models/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];

Copy link
Copy Markdown
Collaborator

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.

};
response: MatterGroupData;
};
delete_group: {
requestArgs: { group_id: number };
response: null;
};
get_groups: {
requestArgs: Record<string, never>;
response: MatterGroupData[];
};
add_group_member: {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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: {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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: {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with this comment. Using send_device_command instead of adding a send_group_command would be consistent with chip-tool's interface. With chip tool, unicast and multicast commands are more or less the same; group commands simply use the nodeid of the group and either omit or ignore the endpoint argument.

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 */
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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[];

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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;
Expand Down Expand Up @@ -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;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 */
Expand Down Expand Up @@ -418,6 +526,15 @@ export interface APIEvents {
webrtc_callback: {
data: WebRtcCallbackData;
};
group_added: {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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 */
Expand Down Expand Up @@ -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
Expand All @@ -474,7 +603,10 @@ export type EventMessage =
| ServerEventEndpointAdded
| ServerEventEndpointRemoved
| ServerEventInfoUpdated
| ServerEventWebRtcCallback;
| ServerEventWebRtcCallback
| ServerEventGroupAdded
| ServerEventGroupUpdated
| ServerEventGroupRemoved;

export interface ResultMessageBase {
message_id: string;
Expand Down
104 changes: 104 additions & 0 deletions packages/ws-controller/src/controller/ControllerCommandHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ import {
AccessControlTarget,
AttributeWriteResult,
BindingTarget,
MatterGroupData,
MatterSoftwareVersion,
NodePingResult,
ServerError,
Expand All @@ -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";

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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();
Expand Down Expand Up @@ -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>,
Expand Down
Loading