@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,1015 @@
1
+ /**
2
+ * @license
3
+ * Copyright 2025-2026 Open Home Foundation
4
+ * SPDX-License-Identifier: Apache-2.0
5
+ */
6
+
7
+ import { consume } from "@lit/context";
8
+ import "@material/web/divider/divider";
9
+ import { isTestNodeId, type MatterClient, type MatterNode } from "@matter-server/ws-client";
10
+ import { mdiClose, mdiRefresh, mdiSignal, mdiSignalCellular1, mdiSignalCellular2 } from "@mdi/js";
11
+ import { LitElement, TemplateResult, css, html, nothing } from "lit";
12
+ import { customElement, property, state } from "lit/decorators.js";
13
+ import { clientContext } from "../../client/client-context.js";
14
+ import "../../components/ha-svg-icon";
15
+ import { formatNodeAddressFromAny, getEffectiveFabricIndex } from "../../util/format_hex.js";
16
+ import type { ThreadNeighbor } from "./network-types.js";
17
+ import type { NodeConnection } from "./network-utils.js";
18
+ import {
19
+ buildExtAddrMap,
20
+ getDeviceName,
21
+ getNetworkType,
22
+ getNodeConnections,
23
+ getRoutableDestinationsCount,
24
+ getSignalColor,
25
+ getSignalColorFromRssi,
26
+ getThreadChannel,
27
+ getThreadExtendedAddressHex,
28
+ getThreadRole,
29
+ getThreadRoleName,
30
+ getWiFiDiagnostics,
31
+ getWiFiSecurityTypeName,
32
+ getWiFiVersionName,
33
+ parseNeighborTable,
34
+ } from "./network-utils.js";
35
+ import "./update-connections-dialog.js";
36
+
37
+ declare global {
38
+ interface HTMLElementTagNameMap {
39
+ "network-details": NetworkDetails;
40
+ }
41
+ }
42
+
43
+ @customElement("network-details")
44
+ export class NetworkDetails extends LitElement {
45
+ @property()
46
+ public selectedNodeId: number | string | null = null;
47
+
48
+ @property({ type: Object })
49
+ public nodes: Record<string, MatterNode> = {};
50
+
51
+ @property({ type: Object })
52
+ public unknownDevices: Map<
53
+ string,
54
+ { extAddressHex: string; isRouter: boolean; seenBy: string[]; bestRssi: number | null }
55
+ > = new Map();
56
+
57
+ @property({ type: Object })
58
+ public wifiAccessPoints: Map<string, { bssid: string; connectedNodes: string[] }> = new Map();
59
+
60
+ @consume({ context: clientContext })
61
+ private client!: MatterClient;
62
+
63
+ @state()
64
+ private _showUpdateDialog: boolean = false;
65
+
66
+ private _handleClose(): void {
67
+ this.dispatchEvent(
68
+ new CustomEvent("close", {
69
+ bubbles: true,
70
+ composed: true,
71
+ }),
72
+ );
73
+ }
74
+
75
+ private _handleSelectNode(nodeId: number | string): void {
76
+ this.dispatchEvent(
77
+ new CustomEvent("select-node", {
78
+ detail: { nodeId },
79
+ bubbles: true,
80
+ composed: true,
81
+ }),
82
+ );
83
+ }
84
+
85
+ /** Handle keyboard interaction for clickable elements (Enter/Space activates) */
86
+ private _handleKeyDown(event: KeyboardEvent, nodeId: number | string): void {
87
+ if (event.key === "Enter" || event.key === " ") {
88
+ event.preventDefault();
89
+ this._handleSelectNode(nodeId);
90
+ }
91
+ }
92
+
93
+ private _formatExtAddress(extAddr: bigint | string | undefined): string {
94
+ if (extAddr === undefined || extAddr === "") return "Unknown";
95
+ if (typeof extAddr === "bigint") {
96
+ return extAddr.toString(16).toUpperCase().padStart(16, "0");
97
+ }
98
+ return extAddr;
99
+ }
100
+
101
+ private _getSignalIcon(neighbor: ThreadNeighbor): string {
102
+ const color = getSignalColor(neighbor);
103
+ if (color === "#4caf50") return mdiSignal; // Strong
104
+ if (color === "#ff9800") return mdiSignalCellular2; // Medium
105
+ return mdiSignalCellular1; // Weak
106
+ }
107
+
108
+ private _getSignalIconFromColor(color: string): string {
109
+ if (color === "#4caf50") return mdiSignal; // Strong
110
+ if (color === "#ff9800") return mdiSignalCellular2; // Medium
111
+ return mdiSignalCellular1; // Weak
112
+ }
113
+
114
+ /**
115
+ * Format a node ID as hex for Matter log format display.
116
+ * Returns format like "@1:7b" for node ID 123.
117
+ */
118
+ private _formatNodeIdHex(nodeId: number | bigint | string): string {
119
+ // For unknown devices (not in nodes), we can't determine if it's a test node,
120
+ // so we use the fabric index if available
121
+ const node = this.nodes[String(nodeId)];
122
+ const isTestNode = node ? isTestNodeId(node.node_id) : false;
123
+ const fabricIndex = getEffectiveFabricIndex(this.client?.serverInfo?.fabric_index, isTestNode);
124
+ return formatNodeAddressFromAny(fabricIndex, nodeId);
125
+ }
126
+
127
+ private _renderWiFiInfo(node: MatterNode): TemplateResult | typeof nothing {
128
+ const wifiDiag = getWiFiDiagnostics(node);
129
+
130
+ if (!wifiDiag.bssid && wifiDiag.rssi === null) {
131
+ return nothing;
132
+ }
133
+
134
+ const signalColor = getSignalColorFromRssi(wifiDiag.rssi);
135
+
136
+ return html`
137
+ <div class="section">
138
+ <h4>WiFi Network</h4>
139
+ ${wifiDiag.bssid
140
+ ? html`
141
+ <div class="info-row">
142
+ <span class="label">BSSID:</span>
143
+ <span class="value mono">${wifiDiag.bssid}</span>
144
+ </div>
145
+ `
146
+ : nothing}
147
+ ${wifiDiag.rssi !== null
148
+ ? html`
149
+ <div class="info-row">
150
+ <span class="label">Signal:</span>
151
+ <span class="value" style="color: ${signalColor}">${wifiDiag.rssi} dBm</span>
152
+ </div>
153
+ `
154
+ : nothing}
155
+ ${wifiDiag.channel !== null
156
+ ? html`
157
+ <div class="info-row">
158
+ <span class="label">Channel:</span>
159
+ <span class="value">${wifiDiag.channel}</span>
160
+ </div>
161
+ `
162
+ : nothing}
163
+ ${wifiDiag.securityType !== null
164
+ ? html`
165
+ <div class="info-row">
166
+ <span class="label">Security:</span>
167
+ <span class="value">${getWiFiSecurityTypeName(wifiDiag.securityType)}</span>
168
+ </div>
169
+ `
170
+ : nothing}
171
+ ${wifiDiag.wifiVersion !== null
172
+ ? html`
173
+ <div class="info-row">
174
+ <span class="label">WiFi Version:</span>
175
+ <span class="value">${getWiFiVersionName(wifiDiag.wifiVersion)}</span>
176
+ </div>
177
+ `
178
+ : nothing}
179
+ </div>
180
+ `;
181
+ }
182
+
183
+ private _renderThreadInfo(node: MatterNode): TemplateResult | typeof nothing {
184
+ const threadRole = getThreadRole(node);
185
+ const channel = getThreadChannel(node);
186
+ const extAddressHex = getThreadExtendedAddressHex(node);
187
+ const extAddrMap = buildExtAddrMap(this.nodes);
188
+
189
+ // Get all connections (bidirectional) - this matches what the graph shows
190
+ // Use string to avoid BigInt precision loss
191
+ const nodeId = String(node.node_id);
192
+ const connections = getNodeConnections(nodeId, this.nodes, extAddrMap);
193
+
194
+ return html`
195
+ <div class="section">
196
+ <h4>Thread Network</h4>
197
+ <div class="info-row">
198
+ <span class="label">Role:</span>
199
+ <span class="value">${getThreadRoleName(threadRole)}</span>
200
+ </div>
201
+ ${channel !== undefined
202
+ ? html`
203
+ <div class="info-row">
204
+ <span class="label">Channel:</span>
205
+ <span class="value">${channel}</span>
206
+ </div>
207
+ `
208
+ : nothing}
209
+ ${extAddressHex
210
+ ? html`
211
+ <div class="info-row">
212
+ <span class="label">Extended Address:</span>
213
+ <span class="value mono">${extAddressHex}</span>
214
+ </div>
215
+ `
216
+ : nothing}
217
+ <div class="info-row">
218
+ <span class="label">Direct neighbors:</span>
219
+ <span class="value">${connections.length}</span>
220
+ </div>
221
+ ${(() => {
222
+ const routableCount = getRoutableDestinationsCount(node);
223
+ return routableCount > 0
224
+ ? html`
225
+ <div class="info-row">
226
+ <span class="label">Routable destinations:</span>
227
+ <span class="value">${routableCount}</span>
228
+ </div>
229
+ `
230
+ : nothing;
231
+ })()}
232
+ </div>
233
+
234
+ ${connections.length > 0
235
+ ? html`
236
+ <md-divider></md-divider>
237
+ <div class="section">
238
+ <h4>Connections (${connections.length})</h4>
239
+ <div class="neighbors-list">
240
+ ${connections.map((conn: NodeConnection) => {
241
+ return html`
242
+ <div
243
+ class="neighbor-item clickable"
244
+ role="button"
245
+ tabindex="0"
246
+ @click=${() => this._handleSelectNode(conn.connectedNodeId)}
247
+ @keydown=${(e: KeyboardEvent) => this._handleKeyDown(e, conn.connectedNodeId)}
248
+ >
249
+ <ha-svg-icon
250
+ .path=${this._getSignalIconFromColor(conn.signalColor)}
251
+ style="--icon-primary-color: ${conn.signalColor}"
252
+ ></ha-svg-icon>
253
+ <div class="neighbor-info">
254
+ <div class="neighbor-name">
255
+ ${conn.connectedNode
256
+ ? html`Node ${conn.connectedNodeId}
257
+ <span class="node-id-hex"
258
+ >${this._formatNodeIdHex(conn.connectedNodeId)}</span
259
+ >: ${getDeviceName(conn.connectedNode)}`
260
+ : html`External: <span class="mono">${conn.extAddressHex}</span>`}
261
+ </div>
262
+ <div class="neighbor-signal">
263
+ ${conn.rssi !== null
264
+ ? html`RSSI: ${conn.rssi} dBm`
265
+ : nothing}${conn.rssi !== null && conn.lqi !== null
266
+ ? ", "
267
+ : nothing}${conn.lqi !== null
268
+ ? html`LQI: ${conn.lqi}`
269
+ : nothing}${conn.bidirectionalLqi !== undefined
270
+ ? html`<span class="route-info"
271
+ >, Bidir: ${conn.bidirectionalLqi}</span
272
+ >`
273
+ : nothing}${conn.pathCost !== undefined
274
+ ? html`<span class="route-info">, Cost: ${conn.pathCost}</span>`
275
+ : nothing}
276
+ ${!conn.isOutgoing
277
+ ? html`<span class="direction-hint">(reverse)</span>`
278
+ : nothing}
279
+ </div>
280
+ </div>
281
+ </div>
282
+ `;
283
+ })}
284
+ </div>
285
+ </div>
286
+ `
287
+ : nothing}
288
+ `;
289
+ }
290
+
291
+ private _renderNodeInfo(node: MatterNode): TemplateResult | typeof nothing {
292
+ const networkType = getNetworkType(node);
293
+
294
+ return html`
295
+ <div class="section">
296
+ <h4>Device Info</h4>
297
+ <div class="info-row">
298
+ <span class="label">Name:</span>
299
+ <span class="value">${getDeviceName(node)}</span>
300
+ </div>
301
+ <div class="info-row">
302
+ <span class="label">Vendor:</span>
303
+ <span class="value">${node.vendorName ?? "Unknown"}</span>
304
+ </div>
305
+ <div class="info-row">
306
+ <span class="label">Product:</span>
307
+ <span class="value">${node.productName ?? "Unknown"}</span>
308
+ </div>
309
+ ${node.serialNumber
310
+ ? html`
311
+ <div class="info-row">
312
+ <span class="label">Serial:</span>
313
+ <span class="value mono">${node.serialNumber}</span>
314
+ </div>
315
+ `
316
+ : nothing}
317
+ <div class="info-row">
318
+ <span class="label">Network:</span>
319
+ <span class="value">${networkType.charAt(0).toUpperCase() + networkType.slice(1)}</span>
320
+ </div>
321
+ <div class="info-row">
322
+ <span class="label">Status:</span>
323
+ <span class="value ${node.available ? "status-online" : "status-offline"}"
324
+ >${node.available ? "Online" : "Offline"}</span
325
+ >
326
+ </div>
327
+ </div>
328
+
329
+ ${networkType === "thread"
330
+ ? html`
331
+ <md-divider></md-divider>
332
+ ${this._renderThreadInfo(node)}
333
+ `
334
+ : nothing}
335
+ ${networkType === "wifi"
336
+ ? html`
337
+ <md-divider></md-divider>
338
+ ${this._renderWiFiInfo(node)}
339
+ `
340
+ : nothing}
341
+ `;
342
+ }
343
+
344
+ /**
345
+ * Find the neighbor entry for an unknown device from a node's neighbor table.
346
+ */
347
+ private _findNeighborEntry(node: MatterNode, unknownExtAddrHex: string): ThreadNeighbor | null {
348
+ const neighbors = parseNeighborTable(node);
349
+ for (const neighbor of neighbors) {
350
+ const neighborHex = this._formatExtAddress(neighbor.extAddress);
351
+ if (neighborHex === unknownExtAddrHex) {
352
+ return neighbor;
353
+ }
354
+ }
355
+ return null;
356
+ }
357
+
358
+ private _renderUnknownDeviceInfo(deviceId: string): TemplateResult | typeof nothing {
359
+ const unknown = this.unknownDevices.get(deviceId);
360
+ if (!unknown) {
361
+ return html`<p>Unknown device data not available</p>`;
362
+ }
363
+
364
+ return html`
365
+ <div class="section">
366
+ <h4>Unknown Device</h4>
367
+ <div class="info-row">
368
+ <span class="label">Type:</span>
369
+ <span class="value">${unknown.isRouter ? "Router (external)" : "End Device (external)"}</span>
370
+ </div>
371
+ <div class="info-row">
372
+ <span class="label">Extended Address:</span>
373
+ <span class="value mono">${unknown.extAddressHex}</span>
374
+ </div>
375
+ ${unknown.bestRssi !== null
376
+ ? html`
377
+ <div class="info-row">
378
+ <span class="label">Best RSSI:</span>
379
+ <span class="value">${unknown.bestRssi} dBm</span>
380
+ </div>
381
+ `
382
+ : nothing}
383
+ </div>
384
+
385
+ ${unknown.seenBy.length > 0
386
+ ? html`
387
+ <md-divider></md-divider>
388
+ <div class="section">
389
+ <h4>Neighbors (${unknown.seenBy.length})</h4>
390
+ <div class="neighbors-list">
391
+ ${unknown.seenBy.map(nodeId => {
392
+ const node = this.nodes[nodeId.toString()];
393
+ if (!node) return nothing;
394
+
395
+ // Find the neighbor entry to get RSSI/LQI
396
+ const neighborEntry = this._findNeighborEntry(node, unknown.extAddressHex);
397
+ const signalColor = neighborEntry ? getSignalColor(neighborEntry) : "#999";
398
+ const rssi = neighborEntry?.avgRssi ?? neighborEntry?.lastRssi ?? null;
399
+ const lqi = neighborEntry?.lqi;
400
+
401
+ return html`
402
+ <div
403
+ class="neighbor-item clickable"
404
+ role="button"
405
+ tabindex="0"
406
+ @click=${() => this._handleSelectNode(nodeId)}
407
+ @keydown=${(e: KeyboardEvent) => this._handleKeyDown(e, nodeId)}
408
+ >
409
+ ${neighborEntry
410
+ ? html`
411
+ <ha-svg-icon
412
+ .path=${this._getSignalIcon(neighborEntry)}
413
+ style="--icon-primary-color: ${signalColor}"
414
+ ></ha-svg-icon>
415
+ `
416
+ : nothing}
417
+ <div class="neighbor-info">
418
+ <div class="neighbor-name">
419
+ Node ${nodeId}
420
+ <span class="node-id-hex">${this._formatNodeIdHex(nodeId)}</span>:
421
+ ${getDeviceName(node)}
422
+ </div>
423
+ ${neighborEntry
424
+ ? html`
425
+ <div class="neighbor-signal">
426
+ ${rssi !== null ? html`RSSI: ${rssi} dBm, ` : nothing}
427
+ ${lqi !== undefined ? html`LQI: ${lqi}` : nothing}
428
+ </div>
429
+ `
430
+ : nothing}
431
+ </div>
432
+ </div>
433
+ `;
434
+ })}
435
+ </div>
436
+ </div>
437
+ `
438
+ : nothing}
439
+
440
+ <md-divider></md-divider>
441
+ <div class="section">
442
+ <p class="hint-text">
443
+ This device appears in Thread neighbor tables but is not commissioned to this fabric. It may be a
444
+ Thread Border Router or a device from another Matter ecosystem.
445
+ </p>
446
+ </div>
447
+ `;
448
+ }
449
+
450
+ /**
451
+ * Determine if update connections button should be shown.
452
+ */
453
+ private _canUpdateConnections(): boolean {
454
+ if (this.selectedNodeId === null) return false;
455
+
456
+ // WiFi APs: no update possible (not a Matter device)
457
+ const isAccessPoint = typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("ap_");
458
+ if (isAccessPoint) return false;
459
+
460
+ // Unknown devices: only if they have online seenBy nodes
461
+ const isUnknown = typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("unknown_");
462
+ if (isUnknown) {
463
+ return this._getOnlineSeenByNodes().length > 0;
464
+ }
465
+
466
+ // Regular nodes: check network type (no update for ethernet)
467
+ const node = this.nodes[this.selectedNodeId.toString()];
468
+ if (!node) return false;
469
+
470
+ const networkType = getNetworkType(node);
471
+ if (networkType === "ethernet" || networkType === "unknown") return false;
472
+
473
+ return true;
474
+ }
475
+
476
+ /**
477
+ * Get the type of the currently selected node for dialog variant.
478
+ */
479
+ private _getSelectedNodeType(): "online" | "offline" | "unknown" {
480
+ if (typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("unknown_")) {
481
+ return "unknown";
482
+ }
483
+
484
+ const node = this.nodes[this.selectedNodeId!.toString()];
485
+ if (!node || node.available === false) {
486
+ return "offline";
487
+ }
488
+ return "online";
489
+ }
490
+
491
+ /**
492
+ * Get online neighbors for a Thread node.
493
+ */
494
+ private _getOnlineNeighbors(nodeId: string): string[] {
495
+ const node = this.nodes[nodeId];
496
+ if (!node) return [];
497
+
498
+ const networkType = getNetworkType(node);
499
+ if (networkType === "thread") {
500
+ const extAddrMap = buildExtAddrMap(this.nodes);
501
+ const connections = getNodeConnections(nodeId, this.nodes, extAddrMap);
502
+ return connections
503
+ .filter(conn => {
504
+ // Only include commissioned nodes (not unknown devices)
505
+ if (typeof conn.connectedNodeId === "string" && conn.connectedNodeId.startsWith("unknown_")) {
506
+ return false;
507
+ }
508
+ const connectedNode = this.nodes[String(conn.connectedNodeId)];
509
+ return connectedNode?.available === true;
510
+ })
511
+ .map(conn => String(conn.connectedNodeId));
512
+ }
513
+
514
+ // WiFi nodes don't have peer connections (just AP)
515
+ return [];
516
+ }
517
+
518
+ /**
519
+ * Get online nodes that see an unknown device.
520
+ */
521
+ private _getOnlineSeenByNodes(): string[] {
522
+ if (typeof this.selectedNodeId !== "string" || !this.selectedNodeId.startsWith("unknown_")) {
523
+ return [];
524
+ }
525
+
526
+ const unknown = this.unknownDevices.get(this.selectedNodeId);
527
+ if (!unknown) return [];
528
+
529
+ return unknown.seenBy.filter(nodeId => {
530
+ const node = this.nodes[nodeId.toString()];
531
+ return node?.available === true;
532
+ });
533
+ }
534
+
535
+ /**
536
+ * Get the name of the selected node for display in dialog.
537
+ */
538
+ private _getSelectedNodeName(): string {
539
+ if (typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("unknown_")) {
540
+ const unknown = this.unknownDevices.get(this.selectedNodeId);
541
+ return unknown ? `Unknown (${unknown.extAddressHex.slice(-8)})` : "Unknown Device";
542
+ }
543
+
544
+ const node = this.nodes[this.selectedNodeId!.toString()];
545
+ return node ? getDeviceName(node) : "Unknown";
546
+ }
547
+
548
+ private _handleUpdateConnections(): void {
549
+ this._showUpdateDialog = true;
550
+ }
551
+
552
+ private _handleDialogClose(): void {
553
+ this._showUpdateDialog = false;
554
+ }
555
+
556
+ private _renderWiFiAccessPointInfo(apId: string): TemplateResult | typeof nothing {
557
+ const ap = this.wifiAccessPoints.get(apId);
558
+ if (!ap) {
559
+ return html`<p>Access point data not available</p>`;
560
+ }
561
+
562
+ return html`
563
+ <div class="section">
564
+ <h4>WiFi Access Point</h4>
565
+ <div class="info-row">
566
+ <span class="label">BSSID:</span>
567
+ <span class="value mono">${ap.bssid}</span>
568
+ </div>
569
+ <div class="info-row">
570
+ <span class="label">Connected devices:</span>
571
+ <span class="value">${ap.connectedNodes.length}</span>
572
+ </div>
573
+ </div>
574
+ ${ap.connectedNodes.length > 0
575
+ ? html`
576
+ <md-divider></md-divider>
577
+ <div class="section">
578
+ <h4>Connected Nodes</h4>
579
+ <div class="connected-nodes-list">
580
+ ${ap.connectedNodes.map(nodeId => {
581
+ const node = this.nodes[nodeId.toString()];
582
+ if (!node) return nothing;
583
+ const wifiDiag = getWiFiDiagnostics(node);
584
+ const signalColor = getSignalColorFromRssi(wifiDiag.rssi);
585
+
586
+ return html`
587
+ <div
588
+ class="connected-node-item clickable"
589
+ role="button"
590
+ tabindex="0"
591
+ @click=${() => this._handleSelectNode(nodeId)}
592
+ @keydown=${(e: KeyboardEvent) => this._handleKeyDown(e, nodeId)}
593
+ >
594
+ <div class="node-name">
595
+ Node ${nodeId}
596
+ <span class="node-id-hex">${this._formatNodeIdHex(nodeId)}</span>:
597
+ ${getDeviceName(node)}
598
+ </div>
599
+ ${wifiDiag.rssi !== null
600
+ ? html`<div class="node-signal" style="color: ${signalColor}">
601
+ ${wifiDiag.rssi} dBm
602
+ </div>`
603
+ : nothing}
604
+ </div>
605
+ `;
606
+ })}
607
+ </div>
608
+ </div>
609
+ `
610
+ : nothing}
611
+ <md-divider></md-divider>
612
+ <div class="section">
613
+ <p class="hint-text">
614
+ This is a WiFi access point that Matter devices connect to. It is not a Matter device itself.
615
+ </p>
616
+ </div>
617
+ `;
618
+ }
619
+
620
+ override render() {
621
+ if (this.selectedNodeId === null) {
622
+ return html`
623
+ <div class="empty-state">
624
+ <p>Select a device to view details</p>
625
+ </div>
626
+ `;
627
+ }
628
+
629
+ // Check if this is an unknown Thread device
630
+ const isUnknown = typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("unknown_");
631
+
632
+ if (isUnknown) {
633
+ const onlineSeenByNodes = this._getOnlineSeenByNodes();
634
+ return html`
635
+ <div class="details-panel">
636
+ <div class="header">
637
+ <h3>External Device</h3>
638
+ <div class="header-actions">
639
+ ${onlineSeenByNodes.length > 0
640
+ ? html`
641
+ <button
642
+ class="action-button"
643
+ @click=${this._handleUpdateConnections}
644
+ aria-label="Update connection data"
645
+ title="Update connection data"
646
+ >
647
+ <ha-svg-icon .path=${mdiRefresh}></ha-svg-icon>
648
+ </button>
649
+ `
650
+ : nothing}
651
+ <button class="close-button" @click=${this._handleClose} aria-label="Close details panel">
652
+ <ha-svg-icon .path=${mdiClose}></ha-svg-icon>
653
+ </button>
654
+ </div>
655
+ </div>
656
+ <div class="content">${this._renderUnknownDeviceInfo(this.selectedNodeId as string)}</div>
657
+ </div>
658
+ ${this._showUpdateDialog
659
+ ? html`
660
+ <update-connections-dialog
661
+ .client=${this.client}
662
+ .nodes=${this.nodes}
663
+ selectedNodeType="unknown"
664
+ .selectedNodeName=${this._getSelectedNodeName()}
665
+ .selectedNodeId=${this.selectedNodeId}
666
+ .onlineNeighborIds=${onlineSeenByNodes}
667
+ @dialog-closed=${this._handleDialogClose}
668
+ ></update-connections-dialog>
669
+ `
670
+ : nothing}
671
+ `;
672
+ }
673
+
674
+ // Check if this is a WiFi access point
675
+ const isAccessPoint = typeof this.selectedNodeId === "string" && this.selectedNodeId.startsWith("ap_");
676
+
677
+ if (isAccessPoint) {
678
+ return html`
679
+ <div class="details-panel">
680
+ <div class="header">
681
+ <h3>Access Point</h3>
682
+ <button class="close-button" @click=${this._handleClose} aria-label="Close details panel">
683
+ <ha-svg-icon .path=${mdiClose}></ha-svg-icon>
684
+ </button>
685
+ </div>
686
+ <div class="content">${this._renderWiFiAccessPointInfo(this.selectedNodeId as string)}</div>
687
+ </div>
688
+ `;
689
+ }
690
+
691
+ const node = this.nodes[this.selectedNodeId.toString()];
692
+ if (!node) {
693
+ return html`
694
+ <div class="empty-state">
695
+ <p>Device not found</p>
696
+ </div>
697
+ `;
698
+ }
699
+
700
+ const canUpdate = this._canUpdateConnections();
701
+ const nodeType = this._getSelectedNodeType();
702
+ const onlineNeighbors = this._getOnlineNeighbors(String(this.selectedNodeId));
703
+
704
+ return html`
705
+ <div class="details-panel">
706
+ <div class="header">
707
+ <h3>
708
+ Node ${this.selectedNodeId}
709
+ <span class="node-id-hex">${this._formatNodeIdHex(this.selectedNodeId)}</span>
710
+ </h3>
711
+ <div class="header-actions">
712
+ ${canUpdate
713
+ ? html`
714
+ <button
715
+ class="action-button"
716
+ @click=${this._handleUpdateConnections}
717
+ aria-label="Update connection data"
718
+ title="Update connection data"
719
+ >
720
+ <ha-svg-icon .path=${mdiRefresh}></ha-svg-icon>
721
+ </button>
722
+ `
723
+ : nothing}
724
+ <button class="close-button" @click=${this._handleClose} aria-label="Close details panel">
725
+ <ha-svg-icon .path=${mdiClose}></ha-svg-icon>
726
+ </button>
727
+ </div>
728
+ </div>
729
+ <div class="content">${this._renderNodeInfo(node)}</div>
730
+ <div class="footer">
731
+ <a href="#node/${this.selectedNodeId}" class="view-link">View node details</a>
732
+ </div>
733
+ </div>
734
+ ${this._showUpdateDialog
735
+ ? html`
736
+ <update-connections-dialog
737
+ .client=${this.client}
738
+ .nodes=${this.nodes}
739
+ .selectedNodeType=${nodeType}
740
+ .selectedNodeName=${this._getSelectedNodeName()}
741
+ .selectedNodeId=${this.selectedNodeId}
742
+ .onlineNeighborIds=${onlineNeighbors}
743
+ @dialog-closed=${this._handleDialogClose}
744
+ ></update-connections-dialog>
745
+ `
746
+ : nothing}
747
+ `;
748
+ }
749
+
750
+ static override styles = css`
751
+ :host {
752
+ display: block;
753
+ height: 100%;
754
+ }
755
+
756
+ .empty-state {
757
+ display: flex;
758
+ align-items: center;
759
+ justify-content: center;
760
+ height: 100%;
761
+ color: var(--md-sys-color-on-surface-variant, #666);
762
+ text-align: center;
763
+ padding: 24px;
764
+ }
765
+
766
+ .details-panel {
767
+ display: flex;
768
+ flex-direction: column;
769
+ height: 100%;
770
+ background-color: var(--md-sys-color-surface, #fff);
771
+ border-radius: 8px;
772
+ border: 1px solid var(--md-sys-color-outline-variant, #ccc);
773
+ overflow: hidden;
774
+ }
775
+
776
+ .header {
777
+ display: flex;
778
+ align-items: center;
779
+ justify-content: space-between;
780
+ padding: 12px 16px;
781
+ background-color: var(--md-sys-color-surface-container, #f5f5f5);
782
+ border-bottom: 1px solid var(--md-sys-color-outline-variant, #ccc);
783
+ }
784
+
785
+ .header h3 {
786
+ margin: 0;
787
+ font-size: 1rem;
788
+ font-weight: 500;
789
+ color: var(--md-sys-color-on-surface, #333);
790
+ }
791
+
792
+ .node-id-hex {
793
+ font-size: 0.75em;
794
+ font-weight: 400;
795
+ color: var(--md-sys-color-on-surface-variant, #666);
796
+ font-family: monospace;
797
+ }
798
+
799
+ .header-actions {
800
+ display: flex;
801
+ align-items: center;
802
+ gap: 4px;
803
+ }
804
+
805
+ .action-button {
806
+ background: none;
807
+ border: none;
808
+ padding: 4px;
809
+ cursor: pointer;
810
+ border-radius: 50%;
811
+ display: flex;
812
+ align-items: center;
813
+ justify-content: center;
814
+ }
815
+
816
+ .action-button:hover {
817
+ background-color: var(--md-sys-color-surface-container-high, #e8e8e8);
818
+ }
819
+
820
+ .action-button ha-svg-icon {
821
+ --icon-primary-color: var(--md-sys-color-on-surface-variant, #666);
822
+ }
823
+
824
+ .close-button {
825
+ background: none;
826
+ border: none;
827
+ padding: 4px;
828
+ cursor: pointer;
829
+ border-radius: 50%;
830
+ display: flex;
831
+ align-items: center;
832
+ justify-content: center;
833
+ }
834
+
835
+ .close-button:hover {
836
+ background-color: var(--md-sys-color-surface-container-high, #e8e8e8);
837
+ }
838
+
839
+ .close-button ha-svg-icon {
840
+ --icon-primary-color: var(--md-sys-color-on-surface-variant, #666);
841
+ }
842
+
843
+ .content {
844
+ flex: 1;
845
+ overflow-y: auto;
846
+ padding: 0;
847
+ }
848
+
849
+ .section {
850
+ padding: 16px;
851
+ }
852
+
853
+ .section h4 {
854
+ margin: 0 0 12px 0;
855
+ font-size: 0.875rem;
856
+ font-weight: 500;
857
+ color: var(--md-sys-color-primary, #6200ee);
858
+ text-transform: uppercase;
859
+ letter-spacing: 0.5px;
860
+ }
861
+
862
+ .info-row {
863
+ display: flex;
864
+ justify-content: space-between;
865
+ padding: 6px 0;
866
+ font-size: 0.875rem;
867
+ }
868
+
869
+ .label {
870
+ color: var(--md-sys-color-on-surface-variant, #666);
871
+ }
872
+
873
+ .value {
874
+ color: var(--md-sys-color-on-surface, #333);
875
+ font-weight: 500;
876
+ text-align: right;
877
+ word-break: break-all;
878
+ max-width: 60%;
879
+ }
880
+
881
+ .value.mono {
882
+ font-family: monospace;
883
+ font-size: 0.8rem;
884
+ }
885
+
886
+ .status-online {
887
+ color: #4caf50;
888
+ }
889
+
890
+ .status-offline {
891
+ color: var(--danger-color, #f44336);
892
+ }
893
+
894
+ .neighbors-list {
895
+ display: flex;
896
+ flex-direction: column;
897
+ gap: 8px;
898
+ }
899
+
900
+ .neighbor-item {
901
+ display: flex;
902
+ align-items: flex-start;
903
+ gap: 12px;
904
+ padding: 8px;
905
+ background-color: var(--md-sys-color-surface-container, #f5f5f5);
906
+ border-radius: 4px;
907
+ }
908
+
909
+ .neighbor-item.clickable {
910
+ cursor: pointer;
911
+ transition: background-color 0.15s;
912
+ }
913
+
914
+ .neighbor-item.clickable:hover {
915
+ background-color: var(--md-sys-color-surface-container-high, #e8e8e8);
916
+ }
917
+
918
+ .neighbor-item ha-svg-icon {
919
+ flex-shrink: 0;
920
+ margin-top: 2px;
921
+ }
922
+
923
+ .neighbor-info {
924
+ flex: 1;
925
+ min-width: 0;
926
+ }
927
+
928
+ .neighbor-name {
929
+ font-size: 0.875rem;
930
+ color: var(--md-sys-color-on-surface, #333);
931
+ word-break: break-word;
932
+ }
933
+
934
+ .neighbor-signal {
935
+ font-size: 0.75rem;
936
+ color: var(--md-sys-color-on-surface-variant, #666);
937
+ margin-top: 2px;
938
+ }
939
+
940
+ .direction-hint {
941
+ font-style: italic;
942
+ opacity: 0.8;
943
+ }
944
+
945
+ .route-info {
946
+ color: var(--md-sys-color-tertiary, #7d5260);
947
+ font-size: 0.85em;
948
+ }
949
+
950
+ .footer {
951
+ padding: 12px 16px;
952
+ border-top: 1px solid var(--md-sys-color-outline-variant, #ccc);
953
+ text-align: center;
954
+ }
955
+
956
+ .view-link {
957
+ color: var(--md-sys-color-primary, #6200ee);
958
+ text-decoration: none;
959
+ font-size: 0.875rem;
960
+ font-weight: 500;
961
+ }
962
+
963
+ .view-link:hover {
964
+ text-decoration: underline;
965
+ }
966
+
967
+ md-divider {
968
+ --md-divider-color: var(--md-sys-color-outline-variant, #ccc);
969
+ }
970
+
971
+ .hint-text {
972
+ font-size: 0.8rem;
973
+ color: var(--md-sys-color-on-surface-variant, #666);
974
+ line-height: 1.4;
975
+ margin: 0;
976
+ }
977
+
978
+ .connected-nodes-list {
979
+ display: flex;
980
+ flex-direction: column;
981
+ gap: 8px;
982
+ }
983
+
984
+ .connected-node-item {
985
+ display: flex;
986
+ align-items: center;
987
+ justify-content: space-between;
988
+ padding: 8px;
989
+ background-color: var(--md-sys-color-surface-container, #f5f5f5);
990
+ border-radius: 4px;
991
+ }
992
+
993
+ .connected-node-item.clickable {
994
+ cursor: pointer;
995
+ transition: background-color 0.15s;
996
+ }
997
+
998
+ .connected-node-item.clickable:hover {
999
+ background-color: var(--md-sys-color-surface-container-high, #e8e8e8);
1000
+ }
1001
+
1002
+ .connected-node-item .node-name {
1003
+ font-size: 0.875rem;
1004
+ color: var(--md-sys-color-on-surface, #333);
1005
+ word-break: break-word;
1006
+ }
1007
+
1008
+ .connected-node-item .node-signal {
1009
+ font-size: 0.8rem;
1010
+ font-weight: 500;
1011
+ flex-shrink: 0;
1012
+ margin-left: 8px;
1013
+ }
1014
+ `;
1015
+ }