Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
18 changes: 18 additions & 0 deletions docs/websocket-api-schema-changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ The server-side constants live in `packages/ws-controller/src/server/WebSocketCo
(`SCHEMA_VERSION`, `MIN_SUPPORTED_SCHEMA_VERSION`). Versions predating this document are not
listed retroactively; entries start at the first version maintained here.

## Schema 13

Minimum supported: 11 (older clients keep working with the pre-13 command shapes).

### Network topology

- **New command `get_network_topology`** → a `NetworkTopology` (`{ collected_at, nodes[], connections[] }`) describing the whole Matter network as a graph: the Thread mesh (nodes + neighbor/route-table links, with external neighbors and mDNS-discovered Border Routers) and the Wi-Fi star (one `wifi_ap` pseudo-node per BSSID, stations linked to it). Ethernet nodes appear unlinked. Optional `refresh` argument re-reads the Thread neighbor/route tables (and Wi-Fi diagnostics) from every online node before building the snapshot (slower — seconds; best-effort, concurrency-capped, under an overall deadline); omitted/`false` builds from the current attribute cache.
- Node kinds: `matter` (commissioned here; carries `node_id`), `border_router`, `thread_unknown` (a neighbour not commissioned on this fabric), `wifi_ap`. Thread connections keep both observed directions (`source_to_target` / `target_to_source`) so asymmetric links stay legible; the top-level `strength` is the strongest observed direction.
Comment thread
MindFreeze marked this conversation as resolved.
Outdated
- **New event `network_topology_updated`** → a `NetworkTopology`, emitted (debounced, latest-wins coalesced) whenever the derived graph changes, plus a slow periodic refresh so sleepy-device drift is eventually reflected. **Delivered only to connections that have issued `get_network_topology`** during their lifetime — mirroring the schema-12 `thread_diagnostics_updated` opt-in so pre-schema-13 clients never receive an event type they didn't subscribe to. **Outgoing events carry no `require_schema`**; clients detect support via `server_info.schema_version >= 13`.

See `packages/ws-client/src/models/model.ts` for the exact `NetworkTopology` / `NetworkTopologyNode` /
`NetworkTopologyConnection` wire shapes (each field documented inline).

> This first iteration derives Thread links from the nodes' own `ThreadNetworkDiagnostics` (neighbor +
> route tables) and classifies externals against the passively-discovered Border Router registry. The
> richer MeshCoP diagnostic enrichment (route64 / childTable → router-to-router links and
> diagnostic-only mesh nodes) is a planned follow-up; the wire model already accommodates it.

## Schema 12

Minimum supported: 11 (older clients keep working with the pre-12 command shapes).
Expand Down
4 changes: 2 additions & 2 deletions packages/matter-server/test/IntegrationTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ describe("Integration Test", function () {

expect(info).to.have.property("fabric_id");
expect(info).to.have.property("compressed_fabric_id");
expect(info.schema_version).to.equal(12);
expect(info.schema_version).to.equal(13);
expect(info.min_supported_schema_version).to.equal(11);
expect(info.sdk_version).to.be.a("string").that.includes("matter-server");
expect(info.sdk_version).to.be.a("string").that.includes("matter.js");
Expand Down Expand Up @@ -172,7 +172,7 @@ describe("Integration Test", function () {
expect(diag).to.have.property("info");
expect(diag).to.have.property("nodes");
expect(diag).to.have.property("events");
expect(diag.info.schema_version).to.equal(12);
expect(diag.info.schema_version).to.equal(13);
expect(diag.nodes).to.be.an("array");
expect(diag.events).to.be.an("array");
});
Expand Down
117 changes: 117 additions & 0 deletions packages/ws-client/src/models/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,105 @@ export interface ThreadDiagnosticsBatch {
partialReason?: ThreadDiagnosticsPartialReason;
}

/**
* Kind of node in a {@link NetworkTopology} graph. @since schema 13
* - `matter`: a commissioned Matter node on this fabric (carries `node_id`).
* - `border_router`: an mDNS-discovered Thread Border Router (not commissioned here).
* - `thread_unknown`: a Thread device seen in a commissioned node's neighbor table but not
* commissioned on this fabric (e.g. a neighbour on another fabric).
* - `wifi_ap`: a synthetic access-point node grouping Wi-Fi stations by BSSID (id `ap_<BSSID>`).
*/
Comment thread
MindFreeze marked this conversation as resolved.
export type TopologyNodeKind = "matter" | "border_router" | "thread_unknown" | "wifi_ap";

/**
* Role of a topology node. Thread roles mirror ThreadNetworkDiagnostics RoutingRole;
* `station`/`ap` describe Wi-Fi. `unassigned`/absent when unknown. @since schema 13
*/
export type TopologyRole =
| "leader"
| "router"
| "reed"
| "end_device"
| "sleepy_end_device"
| "unassigned"
| "station"
| "ap";

/** Link-quality bucket. Thread: LQI 0-3 → none/weak/medium/strong. Wi-Fi: RSSI thresholds. @since schema 13 */
export type TopologyStrength = "strong" | "medium" | "weak" | "none";

/** One direction's observed link quality. @since schema 13 */
export interface TopologyDirectionInfo {
strength: TopologyStrength;
/** Thread Link Quality Indicator (0-3 on OpenThread). */
lqi?: number;
/** RSSI in dBm (Wi-Fi, or Thread neighbor RSSI when available). */
rssi?: number;
}

/**
* A node in the network topology graph. Every field beyond `id`/`kind`/`network_type` is
* optional so the shape degrades gracefully across kinds and schema growth. @since schema 13
*/
export interface NetworkTopologyNode {
/** Stable graph id. Commissioned nodes use the decimal node id; externals use `br_<XA>` /
* `unknown_<EXTADDR>`; Wi-Fi APs use `ap_<BSSID>`. */
id: string;
Comment thread
MindFreeze marked this conversation as resolved.
kind: TopologyNodeKind;
network_type: "thread" | "wifi" | "ethernet" | "unknown";
/** Present for `matter` nodes only. */
node_id?: number | bigint;
role?: TopologyRole;
/** Present for `matter` nodes: reachability of the commissioned node. */
available?: boolean;
is_bridge?: boolean;
/** 16-char uppercase hex Thread extended (MAC) address, when known. */
ext_address?: string;
/** Thread short address within the mesh. */
rloc16?: number;
/** 16-char uppercase hex Thread extended PAN id (network key). */
ext_pan_id?: string;
/** Thread network name, or Wi-Fi BSSID for `wifi_ap` nodes. */
network_name?: string;
vendor_name?: string;
model_name?: string;
/** Epoch ms the node was last observed (border routers). */
last_seen?: number;
}

/**
* An edge between two topology nodes. Thread neighbor links are directional and may be
* asymmetric: `source_to_target` / `target_to_source` carry each observed direction, while
* top-level `strength` is a summary (the strongest observed direction) for simple renderers.
* @since schema 13
*/
export interface NetworkTopologyConnection {
source: string;
target: string;
network: "thread" | "wifi";
/** Summary strength = strongest observed direction. Use the per-direction fields for exact rendering. */
strength: TopologyStrength;
/** source → target observation, when that direction reported the link. */
source_to_target?: TopologyDirectionInfo;
/** target → source observation, when that direction reported the link. */
target_to_source?: TopologyDirectionInfo;
/** True when the edge was supplemented from the Thread route table rather than the neighbor table. */
via_route_table?: boolean;
/** Thread path cost (1 = direct), when known. */
path_cost?: number;
}

/**
* Snapshot of the Matter network topology (Thread mesh + Wi-Fi), returned by
* `get_network_topology` and streamed via `network_topology_updated`. @since schema 13
*/
export interface NetworkTopology {
/** Epoch ms the snapshot was assembled. */
collected_at: number;
nodes: NetworkTopologyNode[];
connections: NetworkTopologyConnection[];
}

export type WebRtcEventType = "offer" | "answer" | "ice_candidates" | "end";

export interface WebRtcIceCandidate {
Expand Down Expand Up @@ -327,6 +426,16 @@ export interface APICommands {
requestArgs: { ext_pan_id?: string; force?: boolean };
response: ThreadDiagnosticsBatch | ThreadDiagnosticsBatch[] | null;
};
/**
* Full Matter network topology (Thread mesh + Wi-Fi). `refresh` re-reads the Thread
* neighbor/route tables (and Wi-Fi diagnostics) from online nodes before building the
* snapshot; omitted/false builds from the current attribute cache. Issuing this command
* also opts the connection in to the `network_topology_updated` event. @since schema 13
*/
get_network_topology: {
requestArgs: { refresh?: boolean };
response: NetworkTopology;
};
open_commissioning_window: {
requestArgs: {
node_id: number | bigint;
Expand Down Expand Up @@ -610,6 +719,9 @@ export interface APIEvents {
thread_diagnostics_updated: {
data: ThreadDiagnosticsBatch;
};
network_topology_updated: {
data: NetworkTopology;
};
webrtc_callback: {
data: WebRtcCallbackData;
};
Expand Down Expand Up @@ -658,6 +770,10 @@ interface ServerEventThreadDiagnosticsUpdated {
event: "thread_diagnostics_updated";
data: ThreadDiagnosticsBatch;
}
interface ServerEventNetworkTopologyUpdated {
event: "network_topology_updated";
data: NetworkTopology;
}
interface ServerEventWebRtcCallback {
event: "webrtc_callback";
data: WebRtcCallbackData;
Expand All @@ -674,6 +790,7 @@ export type EventMessage =
| ServerEventEndpointRemoved
| ServerEventInfoUpdated
| ServerEventThreadDiagnosticsUpdated
| ServerEventNetworkTopologyUpdated
| ServerEventWebRtcCallback;

export interface ResultMessageBase {
Expand Down
27 changes: 27 additions & 0 deletions packages/ws-controller/src/controller/MatterController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { Readable } from "node:stream";
import { ConfigStorage } from "../server/ConfigStorage.js";
import { ControllerCommandHandler } from "./ControllerCommandHandler.js";
import { LegacyDataInjector, LegacyServerData } from "./LegacyDataInjector.js";
import { NetworkTopologyService } from "./NetworkTopologyService.js";
import { resolveServerId } from "./ServerIdResolver.js";
import { ThreadDiagnosticsService } from "./ThreadDiagnosticsService.js";

Expand Down Expand Up @@ -196,6 +197,7 @@ export class MatterController {
#stopped = false;
readonly #credentials = new ThreadCredentialsRegistry();
readonly #threadDiagnostics: ThreadDiagnosticsService;
#networkTopology?: NetworkTopologyService;
#webRtcRequestor?: Endpoint<typeof CameraControllerDevice>;
#services: SharedEnvironmentServices;

Expand Down Expand Up @@ -456,6 +458,30 @@ export class MatterController {
return this.#threadDiagnostics;
}

/**
* Lazily-constructed network topology service. Created on first access (i.e. the first
* client that queries/subscribes topology), wired to the command handler's node cache +
* events and the Border Router registry. Reused across connections.
*/
get networkTopology(): NetworkTopologyService {
Comment thread
MindFreeze marked this conversation as resolved.
if (this.#networkTopology === undefined) {
const handler = this.commandHandler;
this.#networkTopology = new NetworkTopologyService({
listNodes: () => handler.getNodeIds().map(nodeId => handler.getNodeDetails(nodeId)),
readAttributes: (nodeId, paths) =>
Comment thread
MindFreeze marked this conversation as resolved.
Outdated
handler.handleReadAttributes(NodeId(nodeId), paths).then(() => undefined),
borderRouters: this.#borderRouterRegistry,
Comment thread
MindFreeze marked this conversation as resolved.
controllerEvents: {
attributeChanged: handler.events.attributeChanged,
nodeAvailabilityChanged: handler.events.nodeAvailabilityChanged,
nodeAdded: handler.events.nodeAdded,
nodeDecommissioned: handler.events.nodeDecommissioned,
},
});
}
return this.#networkTopology;
}

#registerStoredThreadCredentials(): void {
for (const entry of this.#config.listThreadCredentials()) {
registerThreadCredentialsFromHex(this.#credentials, entry.dataset, `stored:${entry.id}`);
Expand Down Expand Up @@ -577,6 +603,7 @@ export class MatterController {
async stop() {
this.#stopped = true;
await this.#settleBackgroundInit();
this.#networkTopology?.stop();
if (!this.#threadDiagnosticsDisabled) {
await this.#threadDiagnostics.stop();
await this.#borderRouterRegistry.stop();
Expand Down
Loading