@matter-server/dashboard 0.3.2 → 0.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/README.md +76 -0
  2. package/dist/esm/pages/cluster-commands/base-cluster-commands.d.ts +2 -2
  3. package/dist/esm/pages/cluster-commands/base-cluster-commands.d.ts.map +1 -1
  4. package/dist/esm/pages/cluster-commands/base-cluster-commands.js.map +1 -1
  5. package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.d.ts +36 -0
  6. package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.d.ts.map +1 -0
  7. package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.js +159 -0
  8. package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.js.map +6 -0
  9. package/dist/esm/pages/cluster-commands/index.d.ts +1 -0
  10. package/dist/esm/pages/cluster-commands/index.d.ts.map +1 -1
  11. package/dist/esm/pages/cluster-commands/index.js +1 -0
  12. package/dist/esm/pages/cluster-commands/index.js.map +1 -1
  13. package/dist/esm/pages/components/footer.d.ts.map +1 -1
  14. package/dist/esm/pages/components/footer.js +4 -7
  15. package/dist/esm/pages/components/footer.js.map +1 -1
  16. package/dist/esm/pages/components/header.d.ts +5 -0
  17. package/dist/esm/pages/components/header.d.ts.map +1 -1
  18. package/dist/esm/pages/components/header.js +75 -0
  19. package/dist/esm/pages/components/header.js.map +1 -1
  20. package/dist/esm/pages/components/node-details.js +2 -2
  21. package/dist/esm/pages/components/node-details.js.map +1 -1
  22. package/dist/esm/pages/components/server-details.d.ts.map +1 -1
  23. package/dist/esm/pages/components/server-details.js +0 -1
  24. package/dist/esm/pages/components/server-details.js.map +1 -1
  25. package/dist/esm/pages/matter-cluster-view.d.ts.map +1 -1
  26. package/dist/esm/pages/matter-cluster-view.js +9 -4
  27. package/dist/esm/pages/matter-cluster-view.js.map +1 -1
  28. package/dist/esm/pages/matter-dashboard-app.d.ts +12 -0
  29. package/dist/esm/pages/matter-dashboard-app.d.ts.map +1 -1
  30. package/dist/esm/pages/matter-dashboard-app.js +84 -4
  31. package/dist/esm/pages/matter-dashboard-app.js.map +1 -1
  32. package/dist/esm/pages/matter-endpoint-view.d.ts.map +1 -1
  33. package/dist/esm/pages/matter-endpoint-view.js +8 -2
  34. package/dist/esm/pages/matter-endpoint-view.js.map +1 -1
  35. package/dist/esm/pages/matter-network-view.d.ts +52 -0
  36. package/dist/esm/pages/matter-network-view.d.ts.map +1 -0
  37. package/dist/esm/pages/matter-network-view.js +309 -0
  38. package/dist/esm/pages/matter-network-view.js.map +6 -0
  39. package/dist/esm/pages/matter-node-view.d.ts.map +1 -1
  40. package/dist/esm/pages/matter-node-view.js +86 -3
  41. package/dist/esm/pages/matter-node-view.js.map +1 -1
  42. package/dist/esm/pages/matter-server-view.d.ts +4 -0
  43. package/dist/esm/pages/matter-server-view.d.ts.map +1 -1
  44. package/dist/esm/pages/matter-server-view.js +16 -1
  45. package/dist/esm/pages/matter-server-view.js.map +1 -1
  46. package/dist/esm/pages/network/base-network-graph.d.ts +74 -0
  47. package/dist/esm/pages/network/base-network-graph.d.ts.map +1 -0
  48. package/dist/esm/pages/network/base-network-graph.js +411 -0
  49. package/dist/esm/pages/network/base-network-graph.js.map +6 -0
  50. package/dist/esm/pages/network/device-icons.d.ts +52 -0
  51. package/dist/esm/pages/network/device-icons.d.ts.map +1 -0
  52. package/dist/esm/pages/network/device-icons.js +197 -0
  53. package/dist/esm/pages/network/device-icons.js.map +6 -0
  54. package/dist/esm/pages/network/device-panel.d.ts +31 -0
  55. package/dist/esm/pages/network/device-panel.d.ts.map +1 -0
  56. package/dist/esm/pages/network/device-panel.js +183 -0
  57. package/dist/esm/pages/network/device-panel.js.map +6 -0
  58. package/dist/esm/pages/network/network-details.d.ts +77 -0
  59. package/dist/esm/pages/network/network-details.d.ts.map +1 -0
  60. package/dist/esm/pages/network/network-details.js +904 -0
  61. package/dist/esm/pages/network/network-details.js.map +6 -0
  62. package/dist/esm/pages/network/network-types.d.ts +159 -0
  63. package/dist/esm/pages/network/network-types.d.ts.map +1 -0
  64. package/dist/esm/pages/network/network-types.js +19 -0
  65. package/dist/esm/pages/network/network-types.js.map +6 -0
  66. package/dist/esm/pages/network/network-utils.d.ts +196 -0
  67. package/dist/esm/pages/network/network-utils.d.ts.map +1 -0
  68. package/dist/esm/pages/network/network-utils.js +540 -0
  69. package/dist/esm/pages/network/network-utils.js.map +6 -0
  70. package/dist/esm/pages/network/thread-graph.d.ts +27 -0
  71. package/dist/esm/pages/network/thread-graph.d.ts.map +1 -0
  72. package/dist/esm/pages/network/thread-graph.js +137 -0
  73. package/dist/esm/pages/network/thread-graph.js.map +6 -0
  74. package/dist/esm/pages/network/update-connections-dialog.d.ts +55 -0
  75. package/dist/esm/pages/network/update-connections-dialog.d.ts.map +1 -0
  76. package/dist/esm/pages/network/update-connections-dialog.js +284 -0
  77. package/dist/esm/pages/network/update-connections-dialog.js.map +6 -0
  78. package/dist/esm/pages/network/wifi-graph.d.ts +27 -0
  79. package/dist/esm/pages/network/wifi-graph.d.ts.map +1 -0
  80. package/dist/esm/pages/network/wifi-graph.js +169 -0
  81. package/dist/esm/pages/network/wifi-graph.js.map +6 -0
  82. package/dist/esm/util/format_hex.d.ts +18 -0
  83. package/dist/esm/util/format_hex.d.ts.map +1 -1
  84. package/dist/esm/util/format_hex.js +21 -1
  85. package/dist/esm/util/format_hex.js.map +1 -1
  86. package/dist/web/js/{commission-node-dialog-CBSDiqRW.js → commission-node-dialog-CcMuttYO.js} +5 -5
  87. package/dist/web/js/{commission-node-existing-TP6s8Tez.js → commission-node-existing-CqTRDMAr.js} +2 -5
  88. package/dist/web/js/{commission-node-thread-DOB8pu6x.js → commission-node-thread-DgwtTVwK.js} +2 -5
  89. package/dist/web/js/{commission-node-wifi-tzavmk1j.js → commission-node-wifi-XaN2SEnE.js} +2 -5
  90. package/dist/web/js/{dialog-box-Dknil_Be.js → dialog-box-COpDD8i7.js} +2 -2
  91. package/dist/web/js/{fire_event-DRpOSjJR.js → fire_event-mDYWi2sw.js} +1 -1
  92. package/dist/web/js/{log-level-dialog-TXkma-7Z.js → log-level-dialog-Bc32kZVw.js} +2 -3
  93. package/dist/web/js/main.js +1 -1
  94. package/dist/web/js/matter-dashboard-app-CrBHT4fT.js +31606 -0
  95. package/dist/web/js/{node-binding-dialog-D52FCBFP.js → node-binding-dialog-C8fqOJiB.js} +2 -4
  96. package/dist/web/js/prevent_default-D-ohDGsN.js +8 -0
  97. package/package.json +6 -5
  98. package/src/pages/cluster-commands/base-cluster-commands.ts +2 -2
  99. package/src/pages/cluster-commands/clusters/basic-information-commands.ts +171 -0
  100. package/src/pages/cluster-commands/index.ts +1 -0
  101. package/src/pages/components/footer.ts +4 -7
  102. package/src/pages/components/header.ts +81 -0
  103. package/src/pages/components/node-details.ts +3 -3
  104. package/src/pages/components/server-details.ts +0 -1
  105. package/src/pages/matter-cluster-view.ts +11 -4
  106. package/src/pages/matter-dashboard-app.ts +105 -5
  107. package/src/pages/matter-endpoint-view.ts +10 -3
  108. package/src/pages/matter-network-view.ts +325 -0
  109. package/src/pages/matter-node-view.ts +93 -4
  110. package/src/pages/matter-server-view.ts +17 -1
  111. package/src/pages/network/base-network-graph.ts +477 -0
  112. package/src/pages/network/device-icons.ts +283 -0
  113. package/src/pages/network/device-panel.ts +180 -0
  114. package/src/pages/network/network-details.ts +1015 -0
  115. package/src/pages/network/network-types.ts +167 -0
  116. package/src/pages/network/network-utils.ts +861 -0
  117. package/src/pages/network/thread-graph.ts +170 -0
  118. package/src/pages/network/update-connections-dialog.ts +327 -0
  119. package/src/pages/network/wifi-graph.ts +193 -0
  120. package/src/util/format_hex.ts +39 -0
  121. package/dist/web/js/matter-dashboard-app-B7GUghkC.js +0 -17254
  122. package/dist/web/js/outlined-text-field-D1DyKQY-.js +0 -968
  123. package/dist/web/js/prevent_default-BPgSQsuY.js +0 -814
  124. package/dist/web/js/validator-C735j770.js +0 -1122
@@ -0,0 +1,170 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { html } from "lit";
8
+ import { customElement } from "lit/decorators.js";
9
+ import { BaseNetworkGraph } from "./base-network-graph.js";
10
+ import { createNodeIconDataUrl, createUnknownDeviceIconDataUrl } from "./device-icons.js";
11
+ import type { NetworkGraphEdge, NetworkGraphNode, UnknownThreadDevice } from "./network-types.js";
12
+ import {
13
+ buildExtAddrMap,
14
+ buildThreadConnections,
15
+ findUnknownDevices,
16
+ getDeviceName,
17
+ getNetworkType,
18
+ getThreadRole,
19
+ } from "./network-utils.js";
20
+
21
+ declare global {
22
+ interface HTMLElementTagNameMap {
23
+ "thread-graph": ThreadGraph;
24
+ }
25
+ }
26
+
27
+ @customElement("thread-graph")
28
+ export class ThreadGraph extends BaseNetworkGraph {
29
+ /** Cache of unknown devices for the current render */
30
+ private _unknownDevices: UnknownThreadDevice[] = [];
31
+
32
+ /** Cached map of unknown devices (rebuilt in _updateGraph) */
33
+ private _unknownDevicesMapCache: Map<
34
+ string,
35
+ { extAddressHex: string; isRouter: boolean; seenBy: string[]; bestRssi: number | null }
36
+ > = new Map();
37
+
38
+ /** Get unknown devices as a map for use by details panel */
39
+ public get unknownDevicesMap(): Map<
40
+ string,
41
+ { extAddressHex: string; isRouter: boolean; seenBy: string[]; bestRssi: number | null }
42
+ > {
43
+ return this._unknownDevicesMapCache;
44
+ }
45
+
46
+ protected override _updateGraph(): void {
47
+ if (!this._nodesDataSet || !this._edgesDataSet) return;
48
+
49
+ // Clear stored edge colors since we're rebuilding edges
50
+ this._clearOriginalEdgeColors();
51
+
52
+ // Filter to Thread devices only
53
+ const threadNodes = Object.values(this.nodes).filter(node => getNetworkType(node) === "thread");
54
+
55
+ if (threadNodes.length === 0) {
56
+ this._nodesDataSet.clear();
57
+ this._edgesDataSet.clear();
58
+ this._unknownDevices = [];
59
+ this._unknownDevicesMapCache.clear();
60
+ return;
61
+ }
62
+
63
+ // Build extended address map for connection matching
64
+ const extAddrMap = buildExtAddrMap(this.nodes);
65
+
66
+ // Find unknown devices (seen in neighbor tables but not commissioned)
67
+ this._unknownDevices = findUnknownDevices(this.nodes, extAddrMap);
68
+
69
+ // Rebuild the cached map
70
+ this._unknownDevicesMapCache.clear();
71
+ for (const device of this._unknownDevices) {
72
+ this._unknownDevicesMapCache.set(device.id, {
73
+ extAddressHex: device.extAddressHex,
74
+ isRouter: device.isRouter,
75
+ seenBy: device.seenBy,
76
+ bestRssi: device.bestRssi,
77
+ });
78
+ }
79
+
80
+ // Build Thread connections (including to unknown devices)
81
+ const connections = buildThreadConnections(this.nodes, extAddrMap, this._unknownDevices);
82
+
83
+ // Create node data for vis.js - known Thread devices
84
+ // Use string IDs to avoid precision loss for large bigint node IDs
85
+ const graphNodes: NetworkGraphNode[] = threadNodes.map(node => {
86
+ const nodeId = String(node.node_id);
87
+ const threadRole = getThreadRole(node);
88
+ const isSelected = nodeId === String(this._selectedNodeId);
89
+ const isOffline = node.available === false;
90
+
91
+ return {
92
+ id: nodeId,
93
+ label: getDeviceName(node),
94
+ image: createNodeIconDataUrl(node, threadRole, isSelected, isOffline),
95
+ shape: "image",
96
+ networkType: "thread",
97
+ threadRole,
98
+ offline: isOffline,
99
+ };
100
+ });
101
+
102
+ // Add unknown devices with question mark icons
103
+ for (const unknown of this._unknownDevices) {
104
+ const isSelected = unknown.id === this._selectedNodeId;
105
+ graphNodes.push({
106
+ id: unknown.id,
107
+ label: `Unknown (${unknown.extAddressHex.slice(-8)})`,
108
+ image: createUnknownDeviceIconDataUrl(unknown.isRouter, isSelected),
109
+ shape: "image",
110
+ networkType: "thread",
111
+ isUnknown: true,
112
+ });
113
+ }
114
+
115
+ // Create edge data for vis.js
116
+ const graphEdges: NetworkGraphEdge[] = connections.map((conn, index) => {
117
+ const isToUnknown = typeof conn.toNodeId === "string" && conn.toNodeId.startsWith("unknown_");
118
+
119
+ // Check if either endpoint is offline - connection data may be stale
120
+ const fromNode = this.nodes[String(conn.fromNodeId)];
121
+ const toNode = this.nodes[String(conn.toNodeId)];
122
+ const hasOfflineEndpoint = fromNode?.available === false || toNode?.available === false;
123
+
124
+ return {
125
+ id: `edge_${index}`,
126
+ from: conn.fromNodeId,
127
+ to: conn.toNodeId,
128
+ color: {
129
+ color: conn.signalColor,
130
+ highlight: conn.signalColor,
131
+ },
132
+ width: 2,
133
+ title: conn.rssi !== null ? `RSSI: ${conn.rssi} dBm, LQI: ${conn.lqi}` : `LQI: ${conn.lqi}`,
134
+ dashes: isToUnknown || hasOfflineEndpoint, // Dashed lines to unknown or offline devices
135
+ };
136
+ });
137
+
138
+ // Update datasets
139
+ const existingNodeIds = this._nodesDataSet.getIds();
140
+ const newNodeIds = new Set(graphNodes.map(n => n.id));
141
+
142
+ // Remove nodes that no longer exist
143
+ const nodesToRemove = existingNodeIds.filter((id: string | number) => !newNodeIds.has(id));
144
+ if (nodesToRemove.length > 0) {
145
+ this._nodesDataSet.remove(nodesToRemove);
146
+ }
147
+
148
+ // Update or add nodes
149
+ this._nodesDataSet.update(graphNodes);
150
+
151
+ // Replace all edges (simpler than diff for edge data)
152
+ this._edgesDataSet.clear();
153
+ this._edgesDataSet.add(graphEdges);
154
+ }
155
+
156
+ override render() {
157
+ const threadNodes = Object.values(this.nodes).filter(node => getNetworkType(node) === "thread");
158
+
159
+ if (threadNodes.length === 0) {
160
+ return html`
161
+ <div class="empty-state">
162
+ <p>No Thread devices found</p>
163
+ <p class="hint">Thread devices will appear here once commissioned</p>
164
+ </div>
165
+ `;
166
+ }
167
+
168
+ return html`<div class="graph-container"></div>`;
169
+ }
170
+ }
@@ -0,0 +1,327 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import "@material/web/button/filled-button";
8
+ import "@material/web/button/text-button";
9
+ import "@material/web/checkbox/checkbox";
10
+ import "@material/web/dialog/dialog";
11
+ import type { MatterClient, MatterNode } from "@matter-server/ws-client";
12
+ import { mdiLoading } from "@mdi/js";
13
+ import { LitElement, css, html, nothing, svg } from "lit";
14
+ import { customElement, property, state } from "lit/decorators.js";
15
+ import { getNetworkType } from "./network-utils.js";
16
+
17
+ declare global {
18
+ interface HTMLElementTagNameMap {
19
+ "update-connections-dialog": UpdateConnectionsDialog;
20
+ }
21
+ }
22
+
23
+ /** Thread network attributes to read */
24
+ const THREAD_ATTRIBUTE_PATHS = ["0/53/7", "0/53/8", "0/51/0"]; // NeighborTable, RouteTable, NetworkInterfaces
25
+
26
+ /** WiFi network attributes to read */
27
+ const WIFI_ATTRIBUTE_PATHS = ["0/54/0", "0/54/3", "0/54/4"]; // BSSID, Channel, RSSI
28
+
29
+ @customElement("update-connections-dialog")
30
+ export class UpdateConnectionsDialog extends LitElement {
31
+ @property({ type: Object })
32
+ public client!: MatterClient;
33
+
34
+ @property({ type: Object })
35
+ public nodes: Record<string, MatterNode> = {};
36
+
37
+ @property({ type: String })
38
+ public selectedNodeType: "online" | "offline" | "unknown" = "online";
39
+
40
+ @property({ type: String })
41
+ public selectedNodeName: string = "";
42
+
43
+ @property()
44
+ public selectedNodeId: number | string | null = null;
45
+
46
+ @property({ type: Array })
47
+ public onlineNeighborIds: string[] = [];
48
+
49
+ @state()
50
+ private _includeNeighbors: boolean = false;
51
+
52
+ @state()
53
+ private _isUpdating: boolean = false;
54
+
55
+ /** Timeout ID for auto-close */
56
+ private _timeoutId: ReturnType<typeof setTimeout> | null = null;
57
+
58
+ /** Track if we've already dispatched close event to prevent double-firing */
59
+ private _hasClosedEvent: boolean = false;
60
+
61
+ override firstUpdated(): void {
62
+ // Open dialog when component is first rendered
63
+ const dialog = this.shadowRoot?.querySelector("md-dialog") as HTMLElement & { show: () => void };
64
+ dialog?.show();
65
+ }
66
+
67
+ override disconnectedCallback(): void {
68
+ super.disconnectedCallback();
69
+ // Clean up timeout when component is removed
70
+ if (this._timeoutId) {
71
+ clearTimeout(this._timeoutId);
72
+ this._timeoutId = null;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Get the number of nodes that will be updated.
78
+ */
79
+ private get _updateCount(): number {
80
+ if (this.selectedNodeType === "online") {
81
+ return this._includeNeighbors ? 1 + this.onlineNeighborIds.length : 1;
82
+ }
83
+ // offline and unknown: update neighbors only
84
+ return this.onlineNeighborIds.length;
85
+ }
86
+
87
+ /**
88
+ * Get the attribute paths to read for a node based on its network type.
89
+ */
90
+ private _getAttributePathsForNode(nodeId: string): string[] {
91
+ const node = this.nodes[nodeId];
92
+ if (!node) return [];
93
+
94
+ const networkType = getNetworkType(node);
95
+
96
+ if (networkType === "thread") {
97
+ return THREAD_ATTRIBUTE_PATHS;
98
+ }
99
+ if (networkType === "wifi") {
100
+ return WIFI_ATTRIBUTE_PATHS;
101
+ }
102
+ // Ethernet and unknown have no dynamic network data
103
+ return [];
104
+ }
105
+
106
+ /**
107
+ * Get the list of node IDs to update based on current state.
108
+ */
109
+ private _getNodeIdsToUpdate(): string[] {
110
+ if (this.selectedNodeType === "online") {
111
+ const nodeIds = [String(this.selectedNodeId)];
112
+ if (this._includeNeighbors) {
113
+ nodeIds.push(...this.onlineNeighborIds);
114
+ }
115
+ return nodeIds;
116
+ }
117
+ // offline and unknown: update neighbors only
118
+ return this.onlineNeighborIds;
119
+ }
120
+
121
+ private async _executeUpdate(): Promise<void> {
122
+ if (this._isUpdating || this._updateCount === 0) return;
123
+
124
+ this._isUpdating = true;
125
+
126
+ // Set up 30s timeout to auto-close dialog
127
+ this._timeoutId = setTimeout(() => {
128
+ console.warn("Update connections timed out after 30s");
129
+ this._closeDialog();
130
+ }, 30000);
131
+
132
+ try {
133
+ const nodeIds = this._getNodeIdsToUpdate();
134
+
135
+ // Build promises for all node updates
136
+ const updatePromises = nodeIds.map(async nodeIdStr => {
137
+ const node = this.nodes[nodeIdStr];
138
+ if (!node) return;
139
+
140
+ const paths = this._getAttributePathsForNode(nodeIdStr);
141
+ if (paths.length === 0) return;
142
+
143
+ // Use the actual node_id from the node object (number | bigint)
144
+ await this.client.readAttribute(node.node_id, paths);
145
+ });
146
+
147
+ // Wait for all to complete (results come via events, we just need completion)
148
+ await Promise.all(updatePromises);
149
+
150
+ // Close dialog on success
151
+ this._closeDialog();
152
+ } catch (error) {
153
+ console.error("Failed to update connections:", error);
154
+ // Close dialog on error too - don't leave user stuck
155
+ this._closeDialog();
156
+ } finally {
157
+ // Clear timeout if we finished before 30s
158
+ if (this._timeoutId) {
159
+ clearTimeout(this._timeoutId);
160
+ this._timeoutId = null;
161
+ }
162
+ this._isUpdating = false;
163
+ }
164
+ }
165
+
166
+ private _closeDialog(): void {
167
+ // Prevent double-firing the close event
168
+ if (this._hasClosedEvent) return;
169
+ this._hasClosedEvent = true;
170
+
171
+ const dialog = this.shadowRoot?.querySelector("md-dialog") as HTMLElement & { close: () => void };
172
+ dialog?.close();
173
+ // Use 'dialog-closed' to avoid conflicting with network-details 'close' event
174
+ this.dispatchEvent(new CustomEvent("dialog-closed", { bubbles: true, composed: true }));
175
+ }
176
+
177
+ /** Handle native dialog closed event (ESC key, backdrop click, etc.) */
178
+ private _handleDialogClosed(): void {
179
+ this._closeDialog();
180
+ }
181
+
182
+ private _handleCheckboxChange(e: Event): void {
183
+ const checkbox = e.target as HTMLInputElement;
184
+ this._includeNeighbors = checkbox.checked;
185
+ }
186
+
187
+ private _renderOnlineContent(): unknown {
188
+ return html`
189
+ <p>Refresh network information for "<strong>${this.selectedNodeName}</strong>".</p>
190
+ ${this.onlineNeighborIds.length > 0
191
+ ? html`
192
+ <label class="checkbox-row">
193
+ <md-checkbox
194
+ ?checked=${this._includeNeighbors}
195
+ @change=${this._handleCheckboxChange}
196
+ ?disabled=${this._isUpdating}
197
+ ></md-checkbox>
198
+ <span
199
+ >Include ${this.onlineNeighborIds.length} connected online
200
+ neighbor${this.onlineNeighborIds.length !== 1 ? "s" : ""}</span
201
+ >
202
+ </label>
203
+ `
204
+ : nothing}
205
+ `;
206
+ }
207
+
208
+ private _renderOfflineContent(): unknown {
209
+ return html`
210
+ <p>"<strong>${this.selectedNodeName}</strong>" appears to be offline.</p>
211
+ ${this.onlineNeighborIds.length > 0
212
+ ? html`
213
+ <p>
214
+ Update network data from its ${this.onlineNeighborIds.length} online
215
+ neighbor${this.onlineNeighborIds.length !== 1 ? "s" : ""} to refresh connection info.
216
+ </p>
217
+ `
218
+ : html` <p>No online neighbors available to update.</p> `}
219
+ `;
220
+ }
221
+
222
+ private _renderUnknownContent(): unknown {
223
+ return html`
224
+ <p>This device is not commissioned to this fabric and cannot be queried directly.</p>
225
+ ${this.onlineNeighborIds.length > 0
226
+ ? html`
227
+ <p>
228
+ Update network data from ${this.onlineNeighborIds.length}
229
+ node${this.onlineNeighborIds.length !== 1 ? "s" : ""} that
230
+ see${this.onlineNeighborIds.length === 1 ? "s" : ""} this device to refresh info.
231
+ </p>
232
+ `
233
+ : html` <p>No online nodes available that see this device.</p> `}
234
+ `;
235
+ }
236
+
237
+ override render() {
238
+ const buttonText =
239
+ this._updateCount === 0
240
+ ? "No nodes to update"
241
+ : `Update ${this._updateCount} node${this._updateCount !== 1 ? "s" : ""}`;
242
+
243
+ return html`
244
+ <md-dialog @closed=${this._handleDialogClosed}>
245
+ <div slot="headline">Update Connection Data</div>
246
+ <div slot="content">
247
+ ${this.selectedNodeType === "online"
248
+ ? this._renderOnlineContent()
249
+ : this.selectedNodeType === "offline"
250
+ ? this._renderOfflineContent()
251
+ : this._renderUnknownContent()}
252
+ </div>
253
+ <div slot="actions">
254
+ <md-text-button @click=${this._closeDialog} ?disabled=${this._isUpdating}>Cancel</md-text-button>
255
+ <md-filled-button
256
+ @click=${this._executeUpdate}
257
+ ?disabled=${this._isUpdating || this._updateCount === 0}
258
+ >
259
+ ${this._isUpdating
260
+ ? html`<span class="updating-content"
261
+ >${svg`<svg class="spinner" viewBox="0 0 24 24" width="18" height="18"><path fill="currentColor" d="${mdiLoading}"/></svg>`}Updating...</span
262
+ >`
263
+ : buttonText}
264
+ </md-filled-button>
265
+ </div>
266
+ </md-dialog>
267
+ `;
268
+ }
269
+
270
+ static override styles = css`
271
+ md-dialog {
272
+ --md-dialog-container-color: var(--md-sys-color-surface, #fff);
273
+ }
274
+
275
+ [slot="content"] {
276
+ padding: 0 24px;
277
+ }
278
+
279
+ [slot="content"] p {
280
+ margin: 0 0 16px 0;
281
+ font-size: 0.875rem;
282
+ line-height: 1.5;
283
+ color: var(--md-sys-color-on-surface, #333);
284
+ }
285
+
286
+ [slot="content"] p:last-child {
287
+ margin-bottom: 0;
288
+ }
289
+
290
+ .checkbox-row {
291
+ display: flex;
292
+ align-items: center;
293
+ gap: 8px;
294
+ cursor: pointer;
295
+ font-size: 0.875rem;
296
+ color: var(--md-sys-color-on-surface, #333);
297
+ }
298
+
299
+ .updating-content {
300
+ display: inline-flex;
301
+ align-items: center;
302
+ gap: 8px;
303
+ }
304
+
305
+ .spinner {
306
+ animation: spin 1s linear infinite;
307
+ flex-shrink: 0;
308
+ }
309
+
310
+ .updating-content svg {
311
+ color: inherit;
312
+ }
313
+
314
+ @keyframes spin {
315
+ from {
316
+ transform: rotate(0deg);
317
+ }
318
+ to {
319
+ transform: rotate(360deg);
320
+ }
321
+ }
322
+
323
+ md-filled-button {
324
+ min-width: 140px;
325
+ }
326
+ `;
327
+ }
@@ -0,0 +1,193 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { html } from "lit";
8
+ import { customElement } from "lit/decorators.js";
9
+ import { BaseNetworkGraph } from "./base-network-graph.js";
10
+ import { createNodeIconDataUrl, createWiFiRouterIconDataUrl } from "./device-icons.js";
11
+ import type { NetworkGraphEdge, NetworkGraphNode } from "./network-types.js";
12
+ import {
13
+ categorizeDevices,
14
+ getDeviceName,
15
+ getNetworkType,
16
+ getSignalColorFromRssi,
17
+ getWiFiDiagnostics,
18
+ } from "./network-utils.js";
19
+
20
+ declare global {
21
+ interface HTMLElementTagNameMap {
22
+ "wifi-graph": WiFiGraph;
23
+ }
24
+ }
25
+
26
+ /** WiFi access point (router) node info */
27
+ interface WiFiAccessPoint {
28
+ bssid: string;
29
+ /** Connected node IDs as strings to avoid BigInt precision loss */
30
+ connectedNodes: string[];
31
+ }
32
+
33
+ @customElement("wifi-graph")
34
+ export class WiFiGraph extends BaseNetworkGraph {
35
+ /** Cache of access points for the current render */
36
+ private _accessPoints: Map<string, WiFiAccessPoint> = new Map();
37
+
38
+ /** Get access points map for use by details panel */
39
+ public get wifiAccessPointsMap(): Map<string, { bssid: string; connectedNodes: string[] }> {
40
+ return this._accessPoints;
41
+ }
42
+
43
+ /**
44
+ * Override physics for WiFi star topology - needs stronger cluster separation.
45
+ */
46
+ protected override _getPhysicsOptions(): any {
47
+ return {
48
+ enabled: true,
49
+ solver: "forceAtlas2Based",
50
+ forceAtlas2Based: {
51
+ gravitationalConstant: -120, // Stronger repulsion for star topology
52
+ centralGravity: 0.003, // Weaker central pull
53
+ springLength: 100, // Shorter springs keep devices close to their AP
54
+ springConstant: 0.12, // Stronger springs
55
+ damping: 0.4,
56
+ avoidOverlap: 0.8,
57
+ },
58
+ stabilization: {
59
+ enabled: true,
60
+ iterations: 300,
61
+ updateInterval: 25,
62
+ },
63
+ };
64
+ }
65
+
66
+ protected override _updateGraph(): void {
67
+ if (!this._nodesDataSet || !this._edgesDataSet) return;
68
+
69
+ // Clear stored edge colors since we're rebuilding edges
70
+ this._clearOriginalEdgeColors();
71
+
72
+ // Get WiFi devices only (Ethernet has no dynamic network data)
73
+ const categorized = categorizeDevices(this.nodes);
74
+ const wifiNodeIds = categorized.wifi;
75
+
76
+ if (wifiNodeIds.length === 0) {
77
+ this._nodesDataSet.clear();
78
+ this._edgesDataSet.clear();
79
+ this._accessPoints.clear();
80
+ return;
81
+ }
82
+
83
+ // Build access points map from BSSID, keyed by apId
84
+ // wifiNodeIds are already strings from categorizeDevices
85
+ this._accessPoints.clear();
86
+
87
+ for (const nodeId of wifiNodeIds) {
88
+ const node = this.nodes[nodeId];
89
+ if (!node) continue;
90
+
91
+ const wifiDiag = getWiFiDiagnostics(node);
92
+ if (wifiDiag.bssid) {
93
+ const apId = `ap_${wifiDiag.bssid.replace(/:/g, "")}`;
94
+ if (!this._accessPoints.has(apId)) {
95
+ this._accessPoints.set(apId, {
96
+ bssid: wifiDiag.bssid,
97
+ connectedNodes: [],
98
+ });
99
+ }
100
+ this._accessPoints.get(apId)!.connectedNodes.push(nodeId);
101
+ }
102
+ }
103
+
104
+ // Create graph nodes
105
+ const graphNodes: NetworkGraphNode[] = [];
106
+ const graphEdges: NetworkGraphEdge[] = [];
107
+
108
+ // Add access point nodes
109
+ for (const [apId, ap] of this._accessPoints) {
110
+ const bssid = ap.bssid;
111
+ const isSelected = String(apId) === String(this._selectedNodeId);
112
+
113
+ graphNodes.push({
114
+ id: apId,
115
+ label: `AP ${bssid.slice(-8)}`,
116
+ image: createWiFiRouterIconDataUrl(isSelected),
117
+ shape: "image",
118
+ networkType: "wifi",
119
+ isUnknown: true, // Mark as infrastructure
120
+ });
121
+ }
122
+
123
+ // Add device nodes and edges
124
+ // nodeId is already a string from categorizeDevices
125
+ let edgeIndex = 0;
126
+ for (const nodeId of wifiNodeIds) {
127
+ const node = this.nodes[nodeId];
128
+ if (!node) continue;
129
+
130
+ const isSelected = nodeId === String(this._selectedNodeId);
131
+ const isOffline = node.available === false;
132
+ const networkType = getNetworkType(node);
133
+ const wifiDiag = getWiFiDiagnostics(node);
134
+
135
+ graphNodes.push({
136
+ id: nodeId,
137
+ label: getDeviceName(node),
138
+ image: createNodeIconDataUrl(node, undefined, isSelected, isOffline),
139
+ shape: "image",
140
+ networkType: networkType,
141
+ offline: isOffline,
142
+ });
143
+
144
+ // Create edge to access point if we have BSSID
145
+ if (wifiDiag.bssid) {
146
+ const apId = `ap_${wifiDiag.bssid.replace(/:/g, "")}`;
147
+ const signalColor = getSignalColorFromRssi(wifiDiag.rssi);
148
+
149
+ graphEdges.push({
150
+ id: `edge_${edgeIndex++}`,
151
+ from: nodeId,
152
+ to: apId,
153
+ color: {
154
+ color: signalColor,
155
+ highlight: signalColor,
156
+ },
157
+ width: 2,
158
+ title: wifiDiag.rssi !== null ? `RSSI: ${wifiDiag.rssi} dBm` : "RSSI: Unknown",
159
+ dashes: isOffline, // Dashed lines for offline devices
160
+ });
161
+ }
162
+ }
163
+
164
+ // Update datasets
165
+ const existingNodeIds = this._nodesDataSet.getIds();
166
+ const newNodeIds = new Set(graphNodes.map(n => n.id));
167
+
168
+ const nodesToRemove = existingNodeIds.filter((id: string | number) => !newNodeIds.has(id));
169
+ if (nodesToRemove.length > 0) {
170
+ this._nodesDataSet.remove(nodesToRemove);
171
+ }
172
+
173
+ this._nodesDataSet.update(graphNodes);
174
+ this._edgesDataSet.clear();
175
+ this._edgesDataSet.add(graphEdges);
176
+ }
177
+
178
+ override render() {
179
+ const categorized = categorizeDevices(this.nodes);
180
+ const wifiCount = categorized.wifi.length;
181
+
182
+ if (wifiCount === 0) {
183
+ return html`
184
+ <div class="empty-state">
185
+ <p>No WiFi devices found</p>
186
+ <p class="hint">WiFi devices will appear here once commissioned</p>
187
+ </div>
188
+ `;
189
+ }
190
+
191
+ return html`<div class="graph-container"></div>`;
192
+ }
193
+ }