Skip to content
Merged
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
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 — id `ap_<BSSID>` with colons stripped, e.g. `ap_112233445566` — stations linked to it). Ethernet nodes appear unlinked. Optional `refresh` argument re-reads the Thread neighbor/route tables, routing role and network name (and Wi-Fi diagnostics) from every online node before building the snapshot (slower — seconds; best-effort, concurrency-capped, under an overall deadline, and shared between concurrent requesters); 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. `strength` values are `strong` / `medium` / `weak` / `none` / `unknown` — `none` means the link was observed dead (Thread pairs with no live direction are omitted entirely), `unknown` means no measurement was available (e.g. a Wi-Fi station whose RSSI can't be read) and must not be rendered as a dead link. Border Router classification depends on mDNS discovery: when the server runs with Thread diagnostics disabled, no `border_router` nodes appear and every external neighbour is reported as `thread_unknown`.
- **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
122 changes: 122 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,110 @@ 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>`,
* colons stripped — e.g. BSSID `11:22:33:44:55:66` → `ap_112233445566`).
*/
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.
* `none` means the link was observed dead; `unknown` means no measurement was available
* (the link is still there), so it must not be treated as a dead link. @since schema 13
*/
export type TopologyStrength = "strong" | "medium" | "weak" | "none" | "unknown";

/** 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>` with the colons stripped (e.g. `ap_112233445566`). */
id: string;
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 +431,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 +724,9 @@ export interface APIEvents {
thread_diagnostics_updated: {
data: ThreadDiagnosticsBatch;
};
network_topology_updated: {
data: NetworkTopology;
};
webrtc_callback: {
data: WebRtcCallbackData;
};
Expand Down Expand Up @@ -658,6 +775,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 +795,7 @@ export type EventMessage =
| ServerEventEndpointRemoved
| ServerEventInfoUpdated
| ServerEventThreadDiagnosticsUpdated
| ServerEventNetworkTopologyUpdated
| ServerEventWebRtcCallback;

export interface ResultMessageBase {
Expand Down
9 changes: 9 additions & 0 deletions packages/ws-client/src/topology/topology-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,15 @@ export function getThreadRole(node: TopologySourceNode): number | undefined {
return typeof v === "number" ? v : undefined;
}

/**
* Gets the Thread network name a node reports for itself.
* Uses attribute 0/53/2 (NetworkName, nullable per Matter spec).
*/
export function getThreadNetworkName(node: TopologySourceNode): string | undefined {
const v = node.attributes["0/53/2"];
return typeof v === "string" && v.length > 0 ? v : undefined;
}

/**
* Gets the Thread channel for a node.
* Uses attribute 0/53/0 (Channel, nullable per Matter spec).
Expand Down
44 changes: 44 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 @@ -163,6 +164,20 @@ function formatDatasetForLog(ds: OperationalDataset): string {
return fields.join(", ");
}

/**
* The attribute reader {@link NetworkTopologyService} refreshes through.
*
* `fabricFiltered` MUST stay `true`: matter.js only integrates a read into the subscribed
* datasource when the read's filter matches the subscription's (which defaults to filtered),
* so a `false` read is discarded — no cache write, no `attributeChanged`, and the rebuild that
* follows sees the same stale values the refresh was issued to replace.
*/
export function topologyAttributeReader(
handler: Pick<ControllerCommandHandler, "handleReadAttributes">,
): (nodeId: number | bigint, paths: string[]) => Promise<void> {
return (nodeId, paths) => handler.handleReadAttributes(NodeId(nodeId), paths, true).then(() => undefined);
}

/**
* Split an `OtbrRestCapability.baseUrl` (e.g. `http://[fd00::1]:8081`) into the
* host + port the {@link OtbrRestClient} constructor expects. Square-bracketed
Expand Down Expand Up @@ -196,6 +211,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 @@ -457,6 +473,33 @@ 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. Throws once the
* controller is stopped rather than starting a service nothing will shut down again.
*/
get networkTopology(): NetworkTopologyService {
Comment thread
MindFreeze marked this conversation as resolved.
if (this.#networkTopology === undefined) {
if (this.#stopped) {
throw new Error("Controller is stopped");
}
const handler = this.commandHandler;
this.#networkTopology = new NetworkTopologyService({
listNodes: () => handler.getNodeIds().map(nodeId => handler.getNodeDetails(nodeId)),
readAttributes: topologyAttributeReader(handler),
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 @@ -578,6 +621,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