feat(ws-controller): network topology service + get_network_topology API (schema 13) - #832
feat(ws-controller): network topology service + get_network_topology API (schema 13)#832MindFreeze wants to merge 13 commits into
Conversation
|
I'll keep this in draft until I have tested the whole stack. May need to tweak the API |
8e3ec33 to
d46276f
Compare
…ogy API (schema 13)
Derive the full Matter network graph (Thread mesh + Wi-Fi star) server-side and
expose it over the WebSocket API.
- NetworkTopologyService builds the graph from the shared ws-client derivation
pipeline plus the mDNS Border Router registry. Hybrid update model: debounced
change-driven rebuilds, emit-only-when-changed (hash excluding collected_at),
and a slow periodic rebuild for sleepy-device drift. refresh() re-reads Thread
neighbor/route tables (and Wi-Fi diagnostics) from online nodes, concurrency-
capped under an overall deadline.
- get_network_topology {refresh?} command and network_topology_updated event,
bumping SCHEMA_VERSION to 13 (min stays 11). The event is delivered only to
connections that have issued the command, mirroring the schema-12
thread_diagnostics_updated opt-in so pre-13 clients never see a new event type.
- NetworkTopology / node / connection wire types (snake_case), with both observed
Thread link directions preserved and a strongest-direction summary strength.
This first iteration derives Thread links from the nodes' own diagnostics and
classifies externals against the BR registry; the richer MeshCoP enrichment
(route64 / childTable) is a planned follow-up the wire model already accommodates.
d46276f to
08b8536
Compare
There was a problem hiding this comment.
Pull request overview
Adds a server-side network-topology derivation service to the ws-controller layer and exposes it via a schema-13 WebSocket API so clients (e.g. Home Assistant) can consume a ready-made Matter network graph instead of re-deriving it.
Changes:
- Introduces
NetworkTopologyServiceto build and refresh a combined Thread mesh + Wi‑Fi star topology and emit updates when the derived graph changes. - Adds
get_network_topology { refresh? }andnetwork_topology_updated(opt-in per connection) and bumps WebSocket schema to 13. - Extends wire models (
@matter-server/ws-client) withNetworkTopology*types and updates integration tests + schema changelog.
Reviewed changes
Copilot reviewed 12 out of 12 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| python_client/tests/test_integration.py | Updates expected schema_version to 13 in Python integration tests. |
| packages/ws-controller/test/WsBackpressureReproTest.ts | Updates controller stubs to include networkTopology observable. |
| packages/ws-controller/test/WebSocketRequestSerializationTest.ts | Updates controller stubs to include networkTopology observable. |
| packages/ws-controller/test/WebSocketCredentialsApiTest.ts | Adds coverage for get_network_topology and per-connection opt-in event delivery; updates schema assertions. |
| packages/ws-controller/test/SetThreadDatasetCredentialsSyncTest.ts | Updates controller stub to include networkTopology service. |
| packages/ws-controller/test/NetworkTopologyServiceTest.ts | Adds unit tests for topology derivation, debounced emission, refresh fanout, and stop behavior. |
| packages/ws-controller/src/server/WebSocketControllerHandler.ts | Bumps schema to 13; adds command handling and opt-in gated network_topology_updated event sending. |
| packages/ws-controller/src/controller/NetworkTopologyService.ts | New service: builds topology snapshots, refreshes diagnostics under concurrency cap/deadline, emits only on change. |
| packages/ws-controller/src/controller/MatterController.ts | Wires a lazily constructed networkTopology service into the controller and stops it on shutdown. |
| packages/ws-client/src/models/model.ts | Adds schema-13 wire types for NetworkTopology + new command/event definitions. |
| packages/matter-server/test/IntegrationTest.ts | Updates expected schema_version to 13 in JS integration tests. |
| docs/websocket-api-schema-changelog.md | Documents schema 13 changes (command/event + opt-in behavior). |
- Register the per-connection topologyUpdated observer lazily on first get_network_topology, so connections that never request topology don't instantiate the service (timers, event subscriptions) via the lazy getter. - Stop starting new attribute reads once the refresh() deadline expires; in-flight reads still complete (no cancellation for reads) and land in the cache for the next rebuild. - Schedule a rebuild on Border Router `updated` events too, so a BR's network name / metadata resolving later is reflected without waiting for the periodic rebuild.
The synthetic Wi-Fi AP id is `ap_<BSSID>` with colons removed (e.g. `ap_112233445566`); the model doc comments and schema changelog said `ap_<BSSID>` without noting the normalization.
- hashTopology() now sorts nodes (by id) and connections (by source/target/ network) before hashing. The graph is a set, but build order isn't stable (listNodes iteration, neighbor-table arrival order), so an unsorted hash re-emitted network_topology_updated on pure reorderings. - Remove the now-dead networkTopology stubs from the three controller-handler tests that never issue get_network_topology: with the observer registered lazily on opt-in, register() only eagerly subscribes to thread-diagnostics. Comments corrected to match.
refresh() only checked #stopped at entry, so a stop() during the awaited read fan-out still reached #emitIfChanged and broadcast topologyUpdated after shutdown. Re-check #stopped after the await; the caller still gets the built snapshot, but a stopped service no longer emits.
…olated Interpolating the caught error into the message drops the stack trace (and can render [object Object]). Pass it as a separate logger argument, matching the handler's logging convention, so refresh/rebuild failures are diagnosable.
Edge/unknown matching falls back to RLOC16 when extended-address matching fails, and RLOC16 changes when a device re-attaches to the mesh — so refresh() must re-read 0/53/64 alongside the neighbor/route tables. The path constants are now exported and imported by the test, so the two copies can't drift.
Apollon77
left a comment
There was a problem hiding this comment.
Review pass. Solid shape overall — the shared ws-client derivation reuse, the lazy per-connection observer, the sorted hash, and the stop-mid-refresh handling are all right. One functional blocker plus a few robustness items, inline.
Blocker: refresh never actually refreshes — the attribute read is issued with fabricFiltered=false, which matter.js discards for state integration, so the rebuild sees the same stale cache. Details on MatterController.ts.
Not inline-able, one more item: with --disable-thread-diagnostics the BorderRouterRegistry is constructed but never started, so list() stays empty and every Thread neighbour classifies as thread_unknown — the graph silently loses all border_router nodes. Worth a line in the schema changelog (or a flag in the payload) so consumers don't read the absence as "no BRs on the network".
| } | ||
|
|
||
| /** WiFi RSSI → strength (ports the dashboard's -70/-85 dBm thresholds). Unknown RSSI is treated as medium. */ | ||
| function rssiToStrength(rssi: number | null): TopologyStrength { |
There was a problem hiding this comment.
Nit: an unknown RSSI reporting as "medium" is indistinguishable on the wire from a genuinely medium link, and strength is a required field, so consumers can't detect the synthetic value. Consider "none", or make the direction info absent when there is no measurement.
There was a problem hiding this comment.
Not changed — left as your deliberate parity with the dashboard thresholds. Flagging only so the synthetic value is a known property of the wire contract. Leaving open for your call.
There was a problem hiding this comment.
I'll change it to none
There was a problem hiding this comment.
Following up on 7596196: mapping an unmeasured RSSI to "none" fixes the undetectable-synthetic-value problem but lands on the one value that already means something else. mapThreadPair drops Thread pairs whose every observed direction is "none", so a Thread edge can never be "none" on the wire, and consumers use exactly that convention as a liveness test — the dashboard filters on signalLevel !== "none" in network-utils.ts:488, thread-graph.ts:466-467, :576 and :655. A Wi-Fi station whose RSSI cannot be read would ship an edge that any such consumer hides, leaving a live station rendered unlinked from its AP.
Proposed fix, prepared as a commit: TopologyStrength gains "unknown" ("no measurement, link presumed up"), rssiToStrength(null) returns it, and the changelog + TopologyStrength doc spell out the none vs unknown split. Your source_to_target: undefined for the unmeasured case is kept as-is. Schema 13 is unreleased so the union is free to grow, and #917 models strength as an open string set, so the Python client parses it unchanged.
Dashboard visualisation is unaffected: it never imports the wire NetworkTopology/TopologyStrength types — it derives its own graph and colours Wi-Fi edges from raw RSSI via getSignalColorFromRssi (null → medium colour). Worth aligning when the dashboard switches to consuming the server-derived graph: unknown should render neutral-but-live there rather than reusing the medium colour.
Gates on the prepared commit: format/lint/build clean, ws-controller 353/353, ws-client 79/79, dashboard 153/153.
The topology refresh read attributes with fabricFiltered=false. matter.js only integrates a read into the subscribed datasource when the read's filter matches the subscription's (default: filtered), so those reads were discarded — no cache write, no attributeChanged — and the rebuild that followed saw exactly the stale values the refresh was issued to replace. The read path now mirrors the dashboard's update-connections fix. Alongside that: - Concurrent refresh callers share one in-flight run, so N clients requesting a refresh at once no longer multiply the per-node radio traffic. - Externals whose every observed link is dead (LQI 0) are dropped instead of shipping as graph nodes with no edges. - RoutingRole and NetworkName join the refresh path set, and a node's own NetworkName is preferred over the Border Router registry lookup. - The lazy networkTopology getter throws once the controller is stopped rather than starting a service nothing will shut down again; the WS opt-in bails during shutdown so a late request still gets its reply. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
packages/ws-controller/src/controller/NetworkTopologyService.ts:52
- THREAD_REFRESH_PATHS is missing 0/53/4 (ThreadNetworkDiagnostics ExtendedPanId), but the topology build uses getThreadExtendedPanId() to populate ext_pan_id and for Thread edge/unknown resolution. A get_network_topology refresh can therefore rebuild from stale ext_pan_id values after a node re-attaches to a different Thread network.
* Thread neighbor + route tables, interface list, own RLOC16, routing role and network name —
* the inputs to Thread edge derivation and to the built node. RLOC16 (0/53/64) is included
* because edge/unknown matching falls back to it when extended-address matching fails, and it
* changes — like the routing role (0/53/1) — when a device re-attaches to the mesh.
*/
export const THREAD_REFRESH_PATHS = ["0/53/7", "0/53/8", "0/51/0", "0/53/64", "0/53/1", "0/53/2"];
/** WiFi BSSID + channel + RSSI — the inputs to the WiFi star. */
- rssiToStrength: an unmeasured RSSI maps to "none" (no measurement) instead of a synthetic "medium" a consumer couldn't tell from a real medium link; the Wi-Fi connection then carries no source_to_target direction detail - THREAD_REFRESH_PATHS: add ExtendedPanId (0/53/4) so a refresh re-reads it — it changes when a node re-attaches to a different Thread network and feeds ext_pan_id and network resolution
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
packages/ws-controller/src/controller/NetworkTopologyService.ts:291
refresh()can resolve and clear#refreshInFlightas soon as the overall deadline elapses, even though the read fan-out started byrunWithConcurrency(...)is still running in the background (because#runWithDeadlineusesPromise.race). This means a secondrefresh()call can start a new fan-out while reads from the previous refresh are still in flight, multiplying radio traffic and bypassing the intended single in-flight refresh / concurrency cap.
async refresh(): Promise<NetworkTopology> {
if (this.#stopped) return this.#build();
if (this.#refreshInFlight !== undefined) return this.#refreshInFlight;
const inFlight = this.#runRefresh().finally(() => {
if (this.#refreshInFlight === inFlight) this.#refreshInFlight = undefined;
});
this.#refreshInFlight = inFlight;
return inFlight;
}
async #runRefresh(): Promise<NetworkTopology> {
const tasks: Array<() => Promise<void>> = [];
for (const node of this.#opts.listNodes()) {
if (node.available === false) continue;
const networkType = getNetworkType(node);
const paths =
networkType === "thread"
? THREAD_REFRESH_PATHS
: networkType === "wifi"
? WIFI_REFRESH_PATHS
: undefined;
if (paths === undefined) continue;
const nodeId = node.node_id;
tasks.push(() =>
this.#opts
.readAttributes(nodeId, paths)
.catch(err => logger.debug(`refresh read failed node=${nodeId}`, err)),
);
}
await this.#runWithDeadline(tasks);
// stop() may have landed while awaiting the reads; a stopped service must not emit.
const topology = this.#build();
if (!this.#stopped) this.#emitIfChanged(topology);
return topology;
}
/** Cancel timers and unsubscribe. Idempotent. */
stop(): void {
this.#stopped = true;
this.#observers.close();
if (this.#debounceTimer !== undefined) {
clearTimeout(this.#debounceTimer);
this.#debounceTimer = undefined;
}
if (this.#periodicTimer !== undefined) {
clearInterval(this.#periodicTimer);
this.#periodicTimer = undefined;
}
}
#scheduleRebuild(): void {
if (this.#stopped) return;
if (this.#debounceTimer !== undefined) clearTimeout(this.#debounceTimer);
this.#debounceTimer = setTimeout(() => {
this.#debounceTimer = undefined;
this.#rebuildAndEmit();
}, this.#debounceMs);
this.#debounceTimer.unref?.();
}
#rebuildAndEmit(): void {
if (this.#stopped) return;
try {
this.#emitIfChanged(this.#build());
} catch (err) {
logger.warn("topology rebuild failed", err);
}
}
#emitIfChanged(topology: NetworkTopology): void {
const hash = hashTopology(topology);
if (hash === this.#lastHash) return;
this.#lastHash = hash;
logger.debug(`topology changed: ${topology.nodes.length} nodes, ${topology.connections.length} connections`);
this.events.topologyUpdated.emit(topology);
}
async #runWithDeadline(tasks: Array<() => Promise<void>>): Promise<void> {
if (tasks.length === 0) return;
let expired = false;
let timer: NodeJS.Timeout | undefined;
const deadline = new Promise<void>(resolve => {
timer = setTimeout(() => {
expired = true;
resolve();
}, this.#refreshTimeoutMs);
timer.unref?.();
});
try {
await Promise.race([runWithConcurrency(tasks, this.#refreshConcurrency, () => expired), deadline]);
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
…link "none" is the dead-link level: mapThreadPair drops Thread pairs whose every observed direction is "none", and consumers treat it the same way (the dashboard filters on signalLevel !== "none" to decide a link is live). Reporting a Wi-Fi station with an unreadable RSSI as "none" therefore hides a live station from its AP. TopologyStrength gains "unknown" for "no measurement, link presumed up"; schema 13 is unreleased, and the Python client models strength as an open string set, so neither side breaks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (1)
packages/ws-controller/src/server/WebSocketControllerHandler.ts:103
get_network_topologyresponses can be large (potentially hundreds of nodes/edges). Right now the debug log prints the fullresultpayload for this command, which can significantly bloat logs and add overhead. Consider skipping message-content logging forget_network_topology, similar tostart_listening, and only log the command + message id.
// Issuing this (schema 13) opts the connection in to `network_topology_updated`, mirroring the
// thread-diagnostics opt-in: pre-schema-13 clients never subscribed, so they must not receive it.
const NETWORK_TOPOLOGY_OPT_IN_COMMANDS = new Set(["get_network_topology"]);
const skipMessageContentInLogFor = ["start_listening"];
The full topology graph (hundreds of nodes/edges) bloats the debug log with no diagnostic value, like the start_listening node dump already does.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.
Suppressed comments (1)
packages/ws-controller/src/server/WebSocketControllerHandler.ts:598
ensureTopologyObserver()can throw (e.g.MatterController.networkTopologythrows once the controller is stopped). Because it runs inside the message handler beforesendReliable(...), an exception here would prevent the server from sending the response frame (including an error response) and could surface as an unhandled request failure on the client. Wrap the opt-in in a try/catch so the response is always sent even if enabling the observer fails.
if (reqTopology) {
ensureTopologyObserver();
}
Part of the Matter network visualization work for OpenHomeFoundation roadmap #210. Follows #829 (the shared topology-derivation extraction), which this builds on.
Derives the full Matter network graph (Thread mesh + Wi-Fi star) server-side and exposes it over the WebSocket API, so downstream consumers (Home Assistant core / frontend) can relay a ready-made topology instead of each re-deriving it from raw cluster attributes and lacking the server-only mDNS Border Router data.
What's here
NetworkTopologyService— builds the graph from the sharedws-clientderivation pipeline plus the passively-discovered Border Router registry. Conventions mirrorThreadDiagnosticsService(injectedPick<>deps, anObservableevent, testable without a controller). Hybrid update model: debounced change-driven rebuilds, emit-only-when-changed (hash compare excluding the timestamp), and a slow periodic rebuild for sleepy-device drift.refresh()re-reads Thread neighbor/route tables (and Wi-Fi diagnostics) from online nodes, concurrency-capped under an overall deadline.get_network_topology { refresh? }command +network_topology_updatedevent, bumpingSCHEMA_VERSIONto 13 (min supported stays 11). The event is delivered only to connections that have issued the command, mirroring the schema-12thread_diagnostics_updatedopt-in so pre-13 clients never receive an event type they didn't subscribe to.NetworkTopology/ node / connection, snake_case): both observed Thread link directions are preserved, with a strongest-direction summarystrengthfor simple renderers.Scope
This first iteration derives Thread links from the nodes' own
ThreadNetworkDiagnostics(neighbor + route tables) and classifies externals against the BR 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.