@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.
- package/README.md +76 -0
- package/dist/esm/pages/cluster-commands/base-cluster-commands.d.ts +2 -2
- package/dist/esm/pages/cluster-commands/base-cluster-commands.d.ts.map +1 -1
- package/dist/esm/pages/cluster-commands/base-cluster-commands.js.map +1 -1
- package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.d.ts +36 -0
- package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.d.ts.map +1 -0
- package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.js +159 -0
- package/dist/esm/pages/cluster-commands/clusters/basic-information-commands.js.map +6 -0
- package/dist/esm/pages/cluster-commands/index.d.ts +1 -0
- package/dist/esm/pages/cluster-commands/index.d.ts.map +1 -1
- package/dist/esm/pages/cluster-commands/index.js +1 -0
- package/dist/esm/pages/cluster-commands/index.js.map +1 -1
- package/dist/esm/pages/components/footer.d.ts.map +1 -1
- package/dist/esm/pages/components/footer.js +4 -7
- package/dist/esm/pages/components/footer.js.map +1 -1
- package/dist/esm/pages/components/header.d.ts +5 -0
- package/dist/esm/pages/components/header.d.ts.map +1 -1
- package/dist/esm/pages/components/header.js +75 -0
- package/dist/esm/pages/components/header.js.map +1 -1
- package/dist/esm/pages/components/node-details.js +2 -2
- package/dist/esm/pages/components/node-details.js.map +1 -1
- package/dist/esm/pages/components/server-details.d.ts.map +1 -1
- package/dist/esm/pages/components/server-details.js +0 -1
- package/dist/esm/pages/components/server-details.js.map +1 -1
- package/dist/esm/pages/matter-cluster-view.d.ts.map +1 -1
- package/dist/esm/pages/matter-cluster-view.js +9 -4
- package/dist/esm/pages/matter-cluster-view.js.map +1 -1
- package/dist/esm/pages/matter-dashboard-app.d.ts +12 -0
- package/dist/esm/pages/matter-dashboard-app.d.ts.map +1 -1
- package/dist/esm/pages/matter-dashboard-app.js +84 -4
- package/dist/esm/pages/matter-dashboard-app.js.map +1 -1
- package/dist/esm/pages/matter-endpoint-view.d.ts.map +1 -1
- package/dist/esm/pages/matter-endpoint-view.js +8 -2
- package/dist/esm/pages/matter-endpoint-view.js.map +1 -1
- package/dist/esm/pages/matter-network-view.d.ts +52 -0
- package/dist/esm/pages/matter-network-view.d.ts.map +1 -0
- package/dist/esm/pages/matter-network-view.js +309 -0
- package/dist/esm/pages/matter-network-view.js.map +6 -0
- package/dist/esm/pages/matter-node-view.d.ts.map +1 -1
- package/dist/esm/pages/matter-node-view.js +86 -3
- package/dist/esm/pages/matter-node-view.js.map +1 -1
- package/dist/esm/pages/matter-server-view.d.ts +4 -0
- package/dist/esm/pages/matter-server-view.d.ts.map +1 -1
- package/dist/esm/pages/matter-server-view.js +16 -1
- package/dist/esm/pages/matter-server-view.js.map +1 -1
- package/dist/esm/pages/network/base-network-graph.d.ts +74 -0
- package/dist/esm/pages/network/base-network-graph.d.ts.map +1 -0
- package/dist/esm/pages/network/base-network-graph.js +411 -0
- package/dist/esm/pages/network/base-network-graph.js.map +6 -0
- package/dist/esm/pages/network/device-icons.d.ts +52 -0
- package/dist/esm/pages/network/device-icons.d.ts.map +1 -0
- package/dist/esm/pages/network/device-icons.js +197 -0
- package/dist/esm/pages/network/device-icons.js.map +6 -0
- package/dist/esm/pages/network/device-panel.d.ts +31 -0
- package/dist/esm/pages/network/device-panel.d.ts.map +1 -0
- package/dist/esm/pages/network/device-panel.js +183 -0
- package/dist/esm/pages/network/device-panel.js.map +6 -0
- package/dist/esm/pages/network/network-details.d.ts +77 -0
- package/dist/esm/pages/network/network-details.d.ts.map +1 -0
- package/dist/esm/pages/network/network-details.js +904 -0
- package/dist/esm/pages/network/network-details.js.map +6 -0
- package/dist/esm/pages/network/network-types.d.ts +159 -0
- package/dist/esm/pages/network/network-types.d.ts.map +1 -0
- package/dist/esm/pages/network/network-types.js +19 -0
- package/dist/esm/pages/network/network-types.js.map +6 -0
- package/dist/esm/pages/network/network-utils.d.ts +196 -0
- package/dist/esm/pages/network/network-utils.d.ts.map +1 -0
- package/dist/esm/pages/network/network-utils.js +540 -0
- package/dist/esm/pages/network/network-utils.js.map +6 -0
- package/dist/esm/pages/network/thread-graph.d.ts +27 -0
- package/dist/esm/pages/network/thread-graph.d.ts.map +1 -0
- package/dist/esm/pages/network/thread-graph.js +137 -0
- package/dist/esm/pages/network/thread-graph.js.map +6 -0
- package/dist/esm/pages/network/update-connections-dialog.d.ts +55 -0
- package/dist/esm/pages/network/update-connections-dialog.d.ts.map +1 -0
- package/dist/esm/pages/network/update-connections-dialog.js +284 -0
- package/dist/esm/pages/network/update-connections-dialog.js.map +6 -0
- package/dist/esm/pages/network/wifi-graph.d.ts +27 -0
- package/dist/esm/pages/network/wifi-graph.d.ts.map +1 -0
- package/dist/esm/pages/network/wifi-graph.js +169 -0
- package/dist/esm/pages/network/wifi-graph.js.map +6 -0
- package/dist/esm/util/format_hex.d.ts +18 -0
- package/dist/esm/util/format_hex.d.ts.map +1 -1
- package/dist/esm/util/format_hex.js +21 -1
- package/dist/esm/util/format_hex.js.map +1 -1
- package/dist/web/js/{commission-node-dialog-CBSDiqRW.js → commission-node-dialog-CcMuttYO.js} +5 -5
- package/dist/web/js/{commission-node-existing-TP6s8Tez.js → commission-node-existing-CqTRDMAr.js} +2 -5
- package/dist/web/js/{commission-node-thread-DOB8pu6x.js → commission-node-thread-DgwtTVwK.js} +2 -5
- package/dist/web/js/{commission-node-wifi-tzavmk1j.js → commission-node-wifi-XaN2SEnE.js} +2 -5
- package/dist/web/js/{dialog-box-Dknil_Be.js → dialog-box-COpDD8i7.js} +2 -2
- package/dist/web/js/{fire_event-DRpOSjJR.js → fire_event-mDYWi2sw.js} +1 -1
- package/dist/web/js/{log-level-dialog-TXkma-7Z.js → log-level-dialog-Bc32kZVw.js} +2 -3
- package/dist/web/js/main.js +1 -1
- package/dist/web/js/matter-dashboard-app-CrBHT4fT.js +31606 -0
- package/dist/web/js/{node-binding-dialog-D52FCBFP.js → node-binding-dialog-C8fqOJiB.js} +2 -4
- package/dist/web/js/prevent_default-D-ohDGsN.js +8 -0
- package/package.json +6 -5
- package/src/pages/cluster-commands/base-cluster-commands.ts +2 -2
- package/src/pages/cluster-commands/clusters/basic-information-commands.ts +171 -0
- package/src/pages/cluster-commands/index.ts +1 -0
- package/src/pages/components/footer.ts +4 -7
- package/src/pages/components/header.ts +81 -0
- package/src/pages/components/node-details.ts +3 -3
- package/src/pages/components/server-details.ts +0 -1
- package/src/pages/matter-cluster-view.ts +11 -4
- package/src/pages/matter-dashboard-app.ts +105 -5
- package/src/pages/matter-endpoint-view.ts +10 -3
- package/src/pages/matter-network-view.ts +325 -0
- package/src/pages/matter-node-view.ts +93 -4
- package/src/pages/matter-server-view.ts +17 -1
- package/src/pages/network/base-network-graph.ts +477 -0
- package/src/pages/network/device-icons.ts +283 -0
- package/src/pages/network/device-panel.ts +180 -0
- package/src/pages/network/network-details.ts +1015 -0
- package/src/pages/network/network-types.ts +167 -0
- package/src/pages/network/network-utils.ts +861 -0
- package/src/pages/network/thread-graph.ts +170 -0
- package/src/pages/network/update-connections-dialog.ts +327 -0
- package/src/pages/network/wifi-graph.ts +193 -0
- package/src/util/format_hex.ts +39 -0
- package/dist/web/js/matter-dashboard-app-B7GUghkC.js +0 -17254
- package/dist/web/js/outlined-text-field-D1DyKQY-.js +0 -968
- package/dist/web/js/prevent_default-BPgSQsuY.js +0 -814
- 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
|
+
}
|