From 07e5f534690e84b1d3d571a4c77c50952cbbfc89 Mon Sep 17 00:00:00 2001 From: Petar Petrov Date: Wed, 21 May 2025 07:59:44 +0300 Subject: [PATCH] Bluetooth network visualization (#25512) * Bluetooth network visualization * fix category symbol * Add translations * memoize bluetooth data * throttle data updates to 10s * handle proxies that appear as end devices too * fix tab highlighting * memoize fix --- src/components/chart/ha-network-graph.ts | 45 ++- .../bluetooth-advertisement-monitor.ts | 13 + .../bluetooth-config-dashboard-router.ts | 4 + .../bluetooth/bluetooth-config-dashboard.ts | 11 + .../bluetooth-network-visualization.ts | 318 ++++++++++++++++++ .../zha/zha-network-visualization-page.ts | 14 +- src/panels/my/ha-panel-my.ts | 4 + src/translations/en.json | 11 +- 8 files changed, 398 insertions(+), 22 deletions(-) create mode 100644 src/panels/config/integrations/integration-panels/bluetooth/bluetooth-network-visualization.ts diff --git a/src/components/chart/ha-network-graph.ts b/src/components/chart/ha-network-graph.ts index db4463ec41..c9693419dc 100644 --- a/src/components/chart/ha-network-graph.ts +++ b/src/components/chart/ha-network-graph.ts @@ -3,7 +3,7 @@ import type { GraphSeriesOption } from "echarts/charts"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property, state, query } from "lit/decorators"; import type { TopLevelFormatterParams } from "echarts/types/dist/shared"; -import { mdiGoogleCirclesGroup } from "@mdi/js"; +import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js"; import memoizeOne from "memoize-one"; import { listenMediaQuery } from "../../common/dom/media_query"; import type { ECOption } from "../../resources/echarts"; @@ -42,6 +42,7 @@ export interface NetworkLink { type?: "solid" | "dashed" | "dotted"; }; symbolSize?: number | number[]; + symbol?: string; label?: { show?: boolean; formatter?: string; @@ -52,7 +53,7 @@ export interface NetworkLink { export interface NetworkData { nodes: NetworkNode[]; links: NetworkLink[]; - categories?: { name: string }[]; + categories?: { name: string; symbol: string }[]; } // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports @@ -62,7 +63,7 @@ let GraphChart: typeof import("echarts/lib/chart/graph/install"); export class HaNetworkGraph extends LitElement { public chart?: EChartsType; - @property({ attribute: false }) public data?: NetworkData; + @property({ attribute: false }) public data!: NetworkData; @property({ attribute: false }) public tooltipFormatter?: ( params: TopLevelFormatterParams @@ -74,6 +75,8 @@ export class HaNetworkGraph extends LitElement { @state() private _physicsEnabled = true; + @state() private _showLabels = true; + private _listeners: (() => void)[] = []; private _nodePositions: Record = {}; @@ -117,7 +120,8 @@ export class HaNetworkGraph extends LitElement { .data=${this._getSeries( this.data, this._physicsEnabled, - this._reducedMotion + this._reducedMotion, + this._showLabels )} .options=${this._createOptions(this.data?.categories)} height="100%" @@ -133,6 +137,15 @@ export class HaNetworkGraph extends LitElement { "ui.panel.config.common.graph.toggle_physics" )} > + `; } @@ -145,7 +158,10 @@ export class HaNetworkGraph extends LitElement { }, legend: { show: !!categories?.length, - data: categories, + data: categories?.map((category) => ({ + ...category, + icon: category.symbol, + })), top: 8, }, dataZoom: { @@ -156,11 +172,12 @@ export class HaNetworkGraph extends LitElement { ); private _getSeries = memoizeOne( - (data?: NetworkData, physicsEnabled?: boolean, reducedMotion?: boolean) => { - if (!data) { - return []; - } - + ( + data: NetworkData, + physicsEnabled: boolean, + reducedMotion: boolean, + showLabels: boolean + ) => { const containerWidth = this.clientWidth; const containerHeight = this.clientHeight; return [ @@ -172,7 +189,7 @@ export class HaNetworkGraph extends LitElement { roam: true, selectedMode: "single", label: { - show: true, + show: showLabels, position: "right", }, emphasis: { @@ -182,7 +199,7 @@ export class HaNetworkGraph extends LitElement { repulsion: [400, 600], edgeLength: [200, 300], gravity: 0.1, - layoutAnimation: !reducedMotion, + layoutAnimation: !reducedMotion && data.nodes.length < 100, }, edgeSymbol: ["none", "arrow"], edgeSymbolSize: 10, @@ -251,6 +268,10 @@ export class HaNetworkGraph extends LitElement { this._physicsEnabled = !this._physicsEnabled; } + private _toggleLabels() { + this._showLabels = !this._showLabels; + } + static styles = css` :host { display: block; diff --git a/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-advertisement-monitor.ts b/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-advertisement-monitor.ts index d9e53f3b05..c2672ac6d9 100644 --- a/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-advertisement-monitor.ts +++ b/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-advertisement-monitor.ts @@ -27,6 +27,18 @@ import "../../../../../layouts/hass-tabs-subpage-data-table"; import { haStyle } from "../../../../../resources/styles"; import type { HomeAssistant, Route } from "../../../../../types"; import { showBluetoothDeviceInfoDialog } from "./show-dialog-bluetooth-device-info"; +import type { PageNavigation } from "../../../../../layouts/hass-tabs-subpage"; + +export const bluetoothAdvertisementMonitorTabs: PageNavigation[] = [ + { + translationKey: "ui.panel.config.bluetooth.advertisement_monitor", + path: "advertisement-monitor", + }, + { + translationKey: "ui.panel.config.bluetooth.visualization", + path: "visualization", + }, +]; @customElement("bluetooth-advertisement-monitor") export class BluetoothAdvertisementMonitorPanel extends LitElement { @@ -220,6 +232,7 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement { @collapsed-changed=${this._handleCollapseChanged} filter=${this.address || ""} clickable + .tabs=${bluetoothAdvertisementMonitorTabs} > `; } diff --git a/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-config-dashboard-router.ts b/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-config-dashboard-router.ts index 9eb2541515..93e9d4384f 100644 --- a/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-config-dashboard-router.ts +++ b/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-config-dashboard-router.ts @@ -31,6 +31,10 @@ class BluetoothConfigDashboardRouter extends HassRouterPage { tag: "bluetooth-connection-monitor", load: () => import("./bluetooth-connection-monitor"), }, + visualization: { + tag: "bluetooth-network-visualization", + load: () => import("./bluetooth-network-visualization"), + }, }, }; diff --git a/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-config-dashboard.ts b/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-config-dashboard.ts index f5d4ddd50b..4b9fe01df5 100644 --- a/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-config-dashboard.ts +++ b/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-config-dashboard.ts @@ -106,6 +106,13 @@ export class BluetoothConfigDashboard extends LitElement { )} + + ${this.hass.localize( + "ui.panel.config.bluetooth.visualization" + )} + = {}; + + private _unsub_advertisements?: UnsubscribeFunc; + + private _unsub_scanners?: UnsubscribeFunc; + + private _throttledUpdateData = throttle((data: BluetoothDeviceData[]) => { + this._data = data; + }, UPDATE_THROTTLE_TIME); + + public connectedCallback(): void { + super.connectedCallback(); + if (this.hass) { + this._unsub_advertisements = subscribeBluetoothAdvertisements( + this.hass.connection, + (data) => { + if (!this._data.length) { + this._data = data; + } else { + this._throttledUpdateData(data); + } + } + ); + this._unsub_scanners = subscribeBluetoothScannersDetails( + this.hass.connection, + (scanners) => { + this._scanners = scanners; + } + ); + + const devices = Object.values(this.hass.devices); + const bluetoothDevices = devices.filter((device) => + device.connections.find((connection) => connection[0] === "bluetooth") + ); + this._sourceDevices = Object.fromEntries( + bluetoothDevices.map((device) => { + const connection = device.connections.find( + (c) => c[0] === "bluetooth" + )!; + return [connection[1], device]; + }) + ); + } + } + + public disconnectedCallback() { + super.disconnectedCallback(); + if (this._unsub_advertisements) { + this._unsub_advertisements(); + this._unsub_advertisements = undefined; + } + this._throttledUpdateData.cancel(); + if (this._unsub_scanners) { + this._unsub_scanners(); + this._unsub_scanners = undefined; + } + } + + protected render() { + return html` + + + + `; + } + + private _formatNetworkData = memoizeOne( + ( + data: BluetoothDeviceData[], + scanners: BluetoothScannersDetails + ): NetworkData => { + const categories = [ + { + name: this.hass.localize("ui.panel.config.bluetooth.core"), + symbol: "roundRect", + itemStyle: { + color: colorVariables["primary-color"], + }, + }, + { + name: this.hass.localize("ui.panel.config.bluetooth.scanners"), + symbol: "circle", + itemStyle: { + color: colorVariables["cyan-color"], + }, + }, + { + name: this.hass.localize("ui.panel.config.bluetooth.known_devices"), + symbol: "circle", + itemStyle: { + color: colorVariables["teal-color"], + }, + }, + { + name: this.hass.localize("ui.panel.config.bluetooth.unknown_devices"), + symbol: "circle", + itemStyle: { + color: colorVariables["disabled-color"], + }, + }, + ]; + const nodes: NetworkNode[] = [ + { + id: "ha", + name: this.hass.localize("ui.panel.config.bluetooth.core"), + category: 0, + value: 4, + symbol: "roundRect", + symbolSize: 40, + polarDistance: 0, + }, + ]; + const links: NetworkLink[] = []; + Object.values(scanners).forEach((scanner) => { + const scannerDevice = this._sourceDevices[scanner.source]; + nodes.push({ + id: scanner.source, + name: + scannerDevice?.name_by_user || scannerDevice?.name || scanner.name, + category: 1, + value: 5, + symbol: "circle", + symbolSize: 30, + polarDistance: 0.25, + }); + links.push({ + source: "ha", + target: scanner.source, + value: 0, + symbol: "none", + lineStyle: { + width: 3, + color: colorVariables["primary-color"], + }, + }); + }); + data.forEach((node) => { + if (scanners[node.address]) { + // proxies sometimes appear as end devices too + links.push({ + source: node.source, + target: node.address, + value: node.rssi, + symbol: "none", + lineStyle: { + width: this._getLineWidth(node.rssi), + color: colorVariables["primary-color"], + }, + }); + return; + } + const device = this._sourceDevices[node.address]; + nodes.push({ + id: node.address, + name: this._getBluetoothDeviceName(node.address), + value: device ? 1 : 0, + category: device ? 2 : 3, + symbolSize: 20, + }); + links.push({ + source: node.source, + target: node.address, + value: node.rssi, + symbol: "none", + lineStyle: { + width: this._getLineWidth(node.rssi), + color: device + ? colorVariables["primary-color"] + : colorVariables["disabled-color"], + }, + }); + }); + return { nodes, links, categories }; + } + ); + + private _getBluetoothDeviceName(id: string): string { + if (id === "ha") { + return this.hass.localize("ui.panel.config.bluetooth.core"); + } + if (this._sourceDevices[id]) { + return ( + this._sourceDevices[id]?.name_by_user || + this._sourceDevices[id]?.name || + id + ); + } + if (this._scanners[id]) { + return this._scanners[id]?.name || id; + } + return this._data.find((d) => d.address === id)?.name || id; + } + + private _getLineWidth(rssi: number): number { + return rssi > -33 ? 3 : rssi > -66 ? 2 : 1; + } + + private _tooltipFormatter = (params: TopLevelFormatterParams): string => { + const { dataType, data } = params as CallbackDataParams; + let tooltipText = ""; + if (dataType === "edge") { + const { source, target, value } = data as any; + const sourceName = this._getBluetoothDeviceName(source); + const targetName = this._getBluetoothDeviceName(target); + tooltipText = `${sourceName} → ${targetName}`; + if (source !== "ha") { + tooltipText += ` ${this.hass.localize("ui.panel.config.bluetooth.rssi")}: ${value}`; + } + } else { + const { id: address } = data as any; + const name = this._getBluetoothDeviceName(address); + const btDevice = this._data.find((d) => d.address === address); + if (btDevice) { + tooltipText = `${name}
${this.hass.localize("ui.panel.config.bluetooth.address")}: ${address}
${this.hass.localize("ui.panel.config.bluetooth.rssi")}: ${btDevice.rssi}
${this.hass.localize("ui.panel.config.bluetooth.source")}: ${btDevice.source}
${this.hass.localize("ui.panel.config.bluetooth.updated")}: ${relativeTime(new Date(btDevice.time * 1000), this.hass.locale)}`; + } else { + const device = this._sourceDevices[address]; + if (device) { + tooltipText = `${name}
${this.hass.localize("ui.panel.config.bluetooth.address")}: ${address}`; + if (device.area_id) { + const area = this.hass.areas[device.area_id]; + if (area) { + tooltipText += `
${this.hass.localize("ui.panel.config.bluetooth.area")}: ${area.name}`; + } + } + } + } + } + return tooltipText; + }; + + private _handleChartClick(e: CustomEvent): void { + if ( + e.detail.dataType === "node" && + e.detail.event.target.cursor === "pointer" + ) { + const { id } = e.detail.data; + const device = this._sourceDevices[id]; + if (device) { + navigate(`/config/devices/device/${device.id}`); + } + } + } + + static get styles(): CSSResultGroup { + return [ + css` + ha-network-graph { + height: 100%; + } + `, + ]; + } +} + +declare global { + interface HTMLElementTagNameMap { + "bluetooth-network-visualization": BluetoothNetworkVisualization; + } +} diff --git a/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts b/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts index 0ef102c6a0..b480d24d97 100644 --- a/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts +++ b/src/panels/config/integrations/integration-panels/zha/zha-network-visualization-page.ts @@ -54,9 +54,7 @@ export class ZHANetworkVisualizationPage extends LitElement { .narrow=${this.narrow} .isWide=${this.isWide} .route=${this.route} - .header=${this.hass.localize( - "ui.panel.config.zha.visualization.header" - )} + header=${this.hass.localize("ui.panel.config.zha.visualization.header")} > 200 ? 3 : lqi > 100 ? 2 : 1; } } diff --git a/src/panels/my/ha-panel-my.ts b/src/panels/my/ha-panel-my.ts index d41ef36863..39e97a566b 100644 --- a/src/panels/my/ha-panel-my.ts +++ b/src/panels/my/ha-panel-my.ts @@ -119,6 +119,10 @@ export const getMyRedirects = (): Redirects => ({ component: "bluetooth", redirect: "/config/bluetooth/connection-monitor", }, + bluetooth_visualization: { + component: "bluetooth", + redirect: "/config/bluetooth/visualization", + }, config_bluetooth: { component: "bluetooth", redirect: "/config/bluetooth", diff --git a/src/translations/en.json b/src/translations/en.json index bf21cb01dd..932e9a1bf5 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -2113,7 +2113,8 @@ "hide_url": "Hide URL", "copy_link": "Copy link", "graph": { - "toggle_physics": "Toggle physics" + "toggle_physics": "Toggle physics", + "toggle_labels": "Toggle labels" } }, "updates": { @@ -5521,6 +5522,7 @@ "connection_slot_allocations_monitor": "Connection slot allocations monitor", "connection_slot_allocations_monitor_details": "The connection slot allocations monitor displays the (GATT) connection slot allocations for the adapter. This adapter supports up to {slots} simultaneous connections. Each remote Bluetooth device that requires an active connection will use one connection slot while the Bluetooth device is connecting or connected.", "connection_monitor": "Connection monitor", + "visualization": "Visualization", "used_connection_slot_allocations": "Used connection slot allocations", "no_connections": "No active connections", "no_advertisements_found": "No matching Bluetooth advertisements found", @@ -5538,7 +5540,12 @@ "manufacturer_data": "Manufacturer data", "service_data": "Service data", "service_uuids": "Service UUIDs", - "copy_to_clipboard": "[%key:ui::panel::config::automation::editor::copy_to_clipboard%]" + "copy_to_clipboard": "[%key:ui::panel::config::automation::editor::copy_to_clipboard%]", + "area": "Area", + "core": "Home Assistant", + "scanners": "Scanners", + "known_devices": "Known devices", + "unknown_devices": "Unknown devices" }, "dhcp": { "title": "DHCP discovery",