feat(ws-controller): add host_name, ssid and bssid to topology nodes - #993
Conversation
Border routers reached the wire with only vendor_name/model_name, which for some vendors are identical on every unit, so a multi-BR network rendered several identically labelled nodes. Send the mDNS host name as a display label instead, normalized with the helper the dashboard already used, now hoisted into ws-client so both consumers share it. The field is optional and additive, so it needs no schema bump.
There was a problem hiding this comment.
Pull request overview
Adds an optional host_name display label to border_router nodes in the network-topology wire model, sourced from the Border Router mDNS hostname and normalized (strip trailing . and .local), so downstream consumers (e.g., Home Assistant) can render multi-BR networks with distinct labels rather than repeated vendor/model strings.
Changes:
- Extend the
NetworkTopologyNodewire model (TS + Python) with optionalhost_nameand populate it forborder_routernodes inNetworkTopologyService. - Hoist
stripMdnsHostname()into@matter-server/ws-client(shared topology utilities) and re-export it from the dashboard to keep existing imports stable. - Add/extend unit tests and update the WebSocket schema changelog entry to document presence-detectable semantics (no schema bump).
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| python_client/tests/test_network_topology.py | Extends wire fixture + asserts host_name parses and defaults to None when absent. |
| python_client/matter_server/common/models.py | Adds host_name: str | None to NetworkTopologyNode dataclass for parity and forward-compat parsing. |
| packages/ws-controller/test/NetworkTopologyServiceTest.ts | Verifies BR hostname normalization and omission behavior when no hostname is available. |
| packages/ws-controller/src/controller/NetworkTopologyService.ts | Populates host_name for border_router nodes using shared stripMdnsHostname(). |
| packages/ws-client/src/topology/topology-utils.ts | Introduces shared stripMdnsHostname() utility. |
| packages/ws-client/src/models/model.ts | Documents new optional host_name field on NetworkTopologyNode. |
| packages/dashboard/src/pages/network/network-utils.ts | Re-exports stripMdnsHostname from @matter-server/ws-client and removes the dashboard-local copy. |
| docs/websocket-api-schema-changelog.md | Documents host_name addition (presence-detectable only; no version signal). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
wifi_ap nodes carried the BSSID in network_name, which is a radio address rather than a network name, and Wi-Fi stations carried no network identity at all. Add both fields explicitly, on the AP and on the stations joined to it, sourcing the SSID from NetworkCommissioning. network_name keeps its existing value on wifi_ap so current consumers are unaffected. The SSID is best effort: some devices report no network, and networkID only means an SSID on Wi-Fi, so a bssid fallback stays necessary.
…n SSID
The AP pseudo-node took its `ssid` from whichever station happened to be
iterated first for a BSSID, and the dedup guard then froze it. A first
station that reports no NetworkCommissioning network — the case the `bssid`
fallback exists for — left the AP unnamed even when every other station on
the same radio carried a good SSID, and the outcome flipped with node
iteration order. Keep the AP node and backfill `ssid` from any station
joined to it.
Wi-Fi diagnostics and the SSID are now parsed once per station and carried
to the AP loop, which iterates those records instead of re-walking every
node: one base64 decode per station instead of two, and no dependency on
node ids being unique across the source list.
A border router whose SRV target strips to nothing ("`.local.`") reached the
wire as `host_name: ""`, a worse label than absence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Pushed one commit to this branch — reviewing the new fields turned up two issues: The AP's
Both are covered by tests that fail against the unfixed tree. The new Wi-Fi test also pins the AP dedup itself (node count + both edges), which nothing asserted before — the existing tests routed nodes through a One follow-up deliberately left out: the same strip-to-empty case is worse on the dashboard side. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 9 out of 9 changed files in this pull request and generated no new comments.
Suppressed comments (2)
packages/ws-client/src/topology/topology-utils.ts:973
- When the list is non-empty but every entry is explicitly
connected: false, this fallback labels the current BSSID/AP with the first saved, disconnected network.connectedis a required field, and the existing Python path likewise skips disconnected entries (python_client/matter_server/client/client.py:548-555), so returnnullwhen no connected entry exists instead of selectingentries[0].
const chosen = entries.find(entry => (entry["1"] ?? entry.connected) === true) ?? entries[0];
packages/ws-controller/src/controller/NetworkTopologyService.ts:343
refresh()still reads onlyWIFI_REFRESH_PATHS, which omits0/49/1. Consequently,get_network_topology({ refresh: true })cannot populate a missing SSID or replace a stale one after the device changes networks; it rebuilds from the old cached Networks value. Add this attribute to the Wi-Fi refresh paths and cover it in the refresh-path assertion.
const ssid = getWiFiSsid(node) ?? undefined;
A device can list saved networks it is not currently on, and naming the radio after one of those is worse than leaving it to the BSSID. Skip disconnected entries instead of falling back to the first one, matching how the python client already reads the same attribute. Also add the network list to the Wi-Fi refresh paths, so an explicit refresh can fill in an SSID the cache never had or replace one left over from a previous network. The path is now asserted by name, because comparing the read against the constant would accept its absence.
`stripMdnsHostname` returned "" for a target that is nothing but the suffix, and each of its three call sites handled that differently: the topology service guarded only against an undefined host name, the graph label used `?? device.networkName`, which "" does not trigger, so a border router lost both its label and its network-name fallback, and the details label guarded the input rather than the result. Return no label instead, and let the existing fallbacks carry it at every call site. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Type of change
Description
Part of the Matter network visualization work for OpenHomeFoundation roadmap #210. Follows #832 (the topology service and
get_network_topology) and #917 (its Python client counterpart).Three optional, additive fields that let a consumer label topology nodes with something a person recognises. No schema bump; nothing existing changes value.
Border routers:
host_nameBorder routers currently reach the wire as
vendor_name/model_nameonly. Several vendors report the same pair for every unit they ship — Apple reportsApple/BorderRouter— so a home with three of them renders three identically-labelled nodes and the graph becomes unreadable. The dashboard already sidesteps this by deriving a label from the mDNS host name it holds locally (stripMdnsHostname(device.hostname)inthread-graph.ts), but that value never reaches the wire, so every other consumer — Home Assistant's network visualization above all — is stuck with the generic pair.What's here
host_nameonNetworkTopologyNode— optional, populated forborder_routernodes from the passively-discovered Border Router registry'shostname. It is a display label, not an address: the trailing dot and a.localsuffix are stripped ("Cuisine.local."→"Cuisine"), matching how the rest of the topology model is already normalised (uppercasedext_pan_id, colon-stripped BSSIDs inap_…ids, friendly routing-role strings). The raw FQDN stays available verbatim through the schema-12get_thread_border_routers, so nothing is lost.stripMdnsHostnamehoisted intows-client— it was dashboard-local; it now lives beside the rest of the shared topology derivation intopology-utils.tsand is re-exported from the dashboard'snetwork-utils.ts, so existing dashboard imports resolve unchanged. This follows the same move refactor(ws-client): extract shared Thread/WiFi topology derivation #829 made for the derivation pipeline, and it means one implementation of the transform instead of one per consumer.NetworkTopologyNodegainshost_name: str | None = None. This is not cosmetic:dataclass_from_dictconstructs only from declared dataclass fields, so shipping the TypeScript half alone would make the value disappear silently at the Python parse boundary, with no error and no failing test. feat(ws-controller): network topology service + get_network_topology API (schema 13) #832/feat(python-client): get_network_topology + network_topology_updated (schema 13) #917 split JS from Python, but that split does not work for a field addition.Wi-Fi nodes:
ssidandbssidwifi_apnodes carry the BSSID innetwork_name— a radio address under a field whose name promises a network name — and Wi-Fi stations carry no network identity at all. Both now getssidandbssidexplicitly.network_nameonwifi_apis deliberately left at its current value so the dashboard and any existing consumer keep working; it is documented as superseded for Wi-Fi and can be dropped at the next schema bump.The SSID comes from NetworkCommissioning (
0/49/1). ItsnetworkIDis only an SSID on Wi-Fi — it is the extended PAN id on Thread and the interface name on Ethernet — so it is read only for Wi-Fi nodes, and a non-text payload is rejected rather than rendered. Some devices report no network at all, so thebssidfallback is permanent. Worth knowing for consumers: one BSSID is one AP radio, so a mesh legitimately reports severalwifi_apnodes sharing anssid.Scope and compatibility
All three fields are optional and additive.
SCHEMA_VERSIONstays 13 (WebSocketControllerHandler.ts), minimum supported stays 11 — the field is additive and no command or event shape changes. Worth flagging explicitly: schema 13 already shipped in 1.4.0, so unlike the earlier in-place additions to this section there is no version signal for this field. Clients must treat it as presence-detectable only and keep avendor_name/model_namefallback: an absenthost_namemeans either an older server or a border router whose_meshcopSRV host name was never resolved; an absentssidmeans an older server or a device that reports no NetworkCommissioning network. That fallback is permanent, not transitional. The schema changelog entry says so, and the field's JSDoc repeats it.Backing evidence
This is an additive feature rather than a defect fix, so there is no failure log to attach. The behaviour it addresses is visible in the dashboard's own Thread graph, which already prefers the mDNS host name over vendor/model for exactly this reason.
Testing
npm run format/format-verify,npm run build-cleanandnpm run lint(type-aware) clean from the repo root.npm test -w @matter-server/ws-client(89/89) andnpm test -w @matter-server/ws-controller(389/389) green. The full rootnpm testwas not used as the gate here: itsmatter-serverintegration suite needs real mDNS on this machine and fails for unrelated reasons on both this branch andmain.NetworkTopologyServiceTestcovers both directions: a registry entry withhostname: "Cuisine.local."reaches the wire ashost_name: "Cuisine", and an entry without a host name omits the field entirely rather than emitting an empty string.npm run python:lint,npm run python:typecheckandnpm run python:test(76/76) clean. The wire fixture gained the new key; the existing forward-compat test (unknown keys and enum-like values) still passes, and the assertion that a node withouthost_nameparses toNoneguards the dataclass default.test_integration.pyis excluded by that script and its two network-dependent cases fail identically onmainhere.getWiFiSsidis unit-tested directly for the connected-network preference, the named/tag field spellings, an empty list, and both binary-rejection paths (invalid UTF-8, and low bytes that decode as valid UTF-8 control characters).host_nameorssidfrom the service, dropping the SSID control-character guard, dropping the connected-network preference, or dropping the field from the Python dataclass each turns a test red.stripMdnsHostnamemoved but is re-exported from the same module path, and its two call sites are untouched.Checklist
npm testpasses — per workspace (ws-client89/89,ws-controller389/389); see Testing above for the root-suite caveatnpm run format-verifyandnpm run lintpassdocs/websocket-api-schema-changelog.md, schema 13 section