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
This commit is contained in:
Petar Petrov 2025-05-21 07:59:44 +03:00 committed by GitHub
parent 97e0217906
commit 07e5f53469
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 398 additions and 22 deletions

View File

@ -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<string, { x: number; y: number }> = {};
@ -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"
)}
></ha-icon-button>
<ha-icon-button
slot="button"
class=${this._showLabels ? "active" : "inactive"}
.path=${mdiFormatTextVariant}
@click=${this._toggleLabels}
label=${this.hass.localize(
"ui.panel.config.common.graph.toggle_labels"
)}
></ha-icon-button>
</ha-chart-base>`;
}
@ -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;

View File

@ -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}
></hass-tabs-subpage-data-table>
`;
}

View File

@ -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"),
},
},
};

View File

@ -106,6 +106,13 @@ export class BluetoothConfigDashboard extends LitElement {
)}
</ha-button></a
>
<a href="/config/bluetooth/visualization"
><ha-button>
${this.hass.localize(
"ui.panel.config.bluetooth.visualization"
)}
</ha-button></a
>
</div>
</ha-card>
<ha-card
@ -208,6 +215,10 @@ export class BluetoothConfigDashboard extends LitElement {
ha-card {
margin-bottom: 16px;
}
.card-actions {
display: flex;
justify-content: space-between;
}
`,
];
}

View File

@ -0,0 +1,318 @@
import { html, LitElement, css } from "lit";
import type { CSSResultGroup } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type {
CallbackDataParams,
TopLevelFormatterParams,
} from "echarts/types/dist/shared";
import memoizeOne from "memoize-one";
import type { HomeAssistant, Route } from "../../../../../types";
import "../../../../../components/chart/ha-network-graph";
import type {
NetworkData,
NetworkNode,
NetworkLink,
} from "../../../../../components/chart/ha-network-graph";
import type {
BluetoothDeviceData,
BluetoothScannersDetails,
} from "../../../../../data/bluetooth";
import {
subscribeBluetoothAdvertisements,
subscribeBluetoothScannersDetails,
} from "../../../../../data/bluetooth";
import type { DeviceRegistryEntry } from "../../../../../data/device_registry";
import "../../../../../layouts/hass-subpage";
import { colorVariables } from "../../../../../resources/theme/color.globals";
import { navigate } from "../../../../../common/navigate";
import { bluetoothAdvertisementMonitorTabs } from "./bluetooth-advertisement-monitor";
import { relativeTime } from "../../../../../common/datetime/relative_time";
import { throttle } from "../../../../../common/util/throttle";
const UPDATE_THROTTLE_TIME = 10000;
@customElement("bluetooth-network-visualization")
export class BluetoothNetworkVisualization extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public route!: Route;
@state() private _data: BluetoothDeviceData[] = [];
@state() private _scanners: BluetoothScannersDetails = {};
@state() private _sourceDevices: Record<string, DeviceRegistryEntry> = {};
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`
<hass-tabs-subpage
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
header=${this.hass.localize("ui.panel.config.bluetooth.visualization")}
.tabs=${bluetoothAdvertisementMonitorTabs}
>
<ha-network-graph
.hass=${this.hass}
.data=${this._formatNetworkData(this._data, this._scanners)}
.tooltipFormatter=${this._tooltipFormatter}
@chart-click=${this._handleChartClick}
></ha-network-graph>
</hass-tabs-subpage>
`;
}
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 += ` <b>${this.hass.localize("ui.panel.config.bluetooth.rssi")}:</b> ${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 = `<b>${name}</b><br><b>${this.hass.localize("ui.panel.config.bluetooth.address")}:</b> ${address}<br><b>${this.hass.localize("ui.panel.config.bluetooth.rssi")}:</b> ${btDevice.rssi}<br><b>${this.hass.localize("ui.panel.config.bluetooth.source")}:</b> ${btDevice.source}<br><b>${this.hass.localize("ui.panel.config.bluetooth.updated")}:</b> ${relativeTime(new Date(btDevice.time * 1000), this.hass.locale)}`;
} else {
const device = this._sourceDevices[address];
if (device) {
tooltipText = `<b>${name}</b><br><b>${this.hass.localize("ui.panel.config.bluetooth.address")}:</b> ${address}`;
if (device.area_id) {
const area = this.hass.areas[device.area_id];
if (area) {
tooltipText += `<br><b>${this.hass.localize("ui.panel.config.bluetooth.area")}: </b>${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;
}
}

View File

@ -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")}
>
<ha-network-graph
.hass=${this.hass}
@ -164,16 +162,16 @@ export class ZHANetworkVisualizationPage extends LitElement {
const categories = [
{
name: "Coordinator",
icon: "roundRect",
symbol: "roundRect",
itemStyle: { color: primaryColor },
},
{ name: "Router", icon: "circle", itemStyle: { color: routerColor } },
{ name: "Router", symbol: "circle", itemStyle: { color: routerColor } },
{
name: "End Device",
icon: "circle",
symbol: "circle",
itemStyle: { color: endDeviceColor },
},
{ name: "Offline", icon: "circle", itemStyle: { color: offlineColor } },
{ name: "Offline", symbol: "circle", itemStyle: { color: offlineColor } },
];
// Create all the nodes and links
@ -347,7 +345,7 @@ export class ZHANetworkVisualizationPage extends LitElement {
}
private _getLQIWidth(lqi: number): number {
return Math.max(1, Math.floor((lqi / 256) * 4));
return lqi > 200 ? 3 : lqi > 100 ? 2 : 1;
}
}

View File

@ -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",

View File

@ -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",