/** * @license * Copyright 2025-2026 Open Home Foundation * SPDX-License-Identifier: Apache-2.0 */ import { html } from "lit"; import { customElement } from "lit/decorators.js"; import { createNodeIconDataUrl, createWiFiRouterIconDataUrl } from "../../util/device-icons.js"; import { BaseNetworkGraph } from "./base-network-graph.js"; import type { NetworkGraphEdge, NetworkGraphNode } from "./network-types.js"; import { categorizeDevices, getDeviceName, getNetworkType, getSignalColorFromRssi, getWiFiDiagnostics, } from "./network-utils.js"; declare global { interface HTMLElementTagNameMap { "wifi-graph": WiFiGraph; } } /** WiFi access point (router) node info */ interface WiFiAccessPoint { bssid: string; /** Connected node IDs as strings to avoid BigInt precision loss */ connectedNodes: string[]; } @customElement("wifi-graph") export class WiFiGraph extends BaseNetworkGraph { /** Cache of access points for the current render */ private _accessPoints: Map = new Map(); /** Get access points map for use by details panel */ public get wifiAccessPointsMap(): Map { return this._accessPoints; } /** * Override physics for WiFi star topology - needs stronger cluster separation. */ protected override _getPhysicsOptions(): any { return { enabled: true, solver: "forceAtlas2Based", forceAtlas2Based: { gravitationalConstant: -120, // Stronger repulsion for star topology centralGravity: 0.003, // Weaker central pull springLength: 100, // Shorter springs keep devices close to their AP springConstant: 0.12, // Stronger springs damping: 0.4, avoidOverlap: 0.8, }, stabilization: { enabled: true, iterations: 300, updateInterval: 25, }, }; } protected override _updateGraph(): void { if (!this._nodesDataSet || !this._edgesDataSet) return; // Clear stored edge colors since we're rebuilding edges this._clearOriginalEdgeColors(); // Get WiFi devices only (Ethernet has no dynamic network data) const categorized = categorizeDevices(this.nodes); const wifiNodeIds = categorized.wifi; if (wifiNodeIds.length === 0) { this._nodesDataSet.clear(); this._edgesDataSet.clear(); this._accessPoints.clear(); return; } // Build access points map from BSSID, keyed by apId // wifiNodeIds are already strings from categorizeDevices this._accessPoints.clear(); for (const nodeId of wifiNodeIds) { const node = this.nodes[nodeId]; if (!node) continue; const wifiDiag = getWiFiDiagnostics(node); if (wifiDiag.bssid) { const apId = `ap_${wifiDiag.bssid.replace(/:/g, "")}`; if (!this._accessPoints.has(apId)) { this._accessPoints.set(apId, { bssid: wifiDiag.bssid, connectedNodes: [], }); } this._accessPoints.get(apId)!.connectedNodes.push(nodeId); } } // Create graph nodes const graphNodes: NetworkGraphNode[] = []; const graphEdges: NetworkGraphEdge[] = []; // Add access point nodes for (const [apId, ap] of this._accessPoints) { const bssid = ap.bssid; const isSelected = String(apId) === String(this._selectedNodeId); graphNodes.push({ id: apId, label: `AP ${bssid}`, image: createWiFiRouterIconDataUrl(isSelected), shape: "image", networkType: "wifi", isUnknown: true, // Mark as infrastructure }); } // Add device nodes and edges // nodeId is already a string from categorizeDevices let edgeIndex = 0; for (const nodeId of wifiNodeIds) { const node = this.nodes[nodeId]; if (!node) continue; const isSelected = nodeId === String(this._selectedNodeId); const isOffline = node.available === false; const networkType = getNetworkType(node); const wifiDiag = getWiFiDiagnostics(node); graphNodes.push({ id: nodeId, label: getDeviceName(node), image: createNodeIconDataUrl(node, undefined, isSelected, isOffline), shape: "image", networkType: networkType, offline: isOffline, }); // Create edge to access point if we have BSSID if (wifiDiag.bssid) { const apId = `ap_${wifiDiag.bssid.replace(/:/g, "")}`; const signalColor = getSignalColorFromRssi(wifiDiag.rssi); graphEdges.push({ id: `edge_${edgeIndex++}`, from: nodeId, to: apId, color: { color: signalColor, highlight: signalColor, }, width: 2, title: wifiDiag.rssi !== null ? `RSSI: ${wifiDiag.rssi} dBm` : "RSSI: Unknown", dashes: isOffline, // Dashed lines for offline devices }); } } // Update datasets const existingNodeIds = this._nodesDataSet.getIds(); const newNodeIds = new Set(graphNodes.map(n => n.id)); const nodesToRemove = existingNodeIds.filter((id: string | number) => !newNodeIds.has(id)); if (nodesToRemove.length > 0) { this._nodesDataSet.remove(nodesToRemove); } this._nodesDataSet.update(graphNodes); this._edgesDataSet.clear(); this._edgesDataSet.add(graphEdges); } override render() { const categorized = categorizeDevices(this.nodes); const wifiCount = categorized.wifi.length; if (wifiCount === 0) { return html`

No WiFi devices found

WiFi devices will appear here once commissioned

`; } return html`
`; } }