From 61effc3f7050be6dda9373573bbfa5c936237287 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 4 Mar 2025 07:29:15 -1000 Subject: [PATCH] Add panel to show Bluetooth connection overview (#24463) * Add panel to show Bluetooth connection overview * tweak * migrate to ha-button --- src/data/bluetooth.ts | 5 + .../bluetooth-config-dashboard-router.ts | 4 + .../bluetooth/bluetooth-config-dashboard.ts | 19 +- .../bluetooth/bluetooth-connection-monitor.ts | 245 ++++++++++++++++++ src/translations/en.json | 2 + 5 files changed, 270 insertions(+), 5 deletions(-) create mode 100644 src/panels/config/integrations/integration-panels/bluetooth/bluetooth-connection-monitor.ts diff --git a/src/data/bluetooth.ts b/src/data/bluetooth.ts index 59a6e99752..901d51672f 100644 --- a/src/data/bluetooth.ts +++ b/src/data/bluetooth.ts @@ -19,6 +19,11 @@ export interface BluetoothDeviceData extends DataTableRowData { tx_power: number; } +export interface BluetoothConnectionData extends DataTableRowData { + address: string; + source: string; +} + export interface BluetoothScannerDetails { source: string; connectable: boolean; 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 105803910a..9eb2541515 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 @@ -27,6 +27,10 @@ class BluetoothConfigDashboardRouter extends HassRouterPage { tag: "bluetooth-advertisement-monitor", load: () => import("./bluetooth-advertisement-monitor"), }, + "connection-monitor": { + tag: "bluetooth-connection-monitor", + load: () => import("./bluetooth-connection-monitor"), + }, }, }; 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 281cab9695..f5d4ddd50b 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 @@ -1,4 +1,3 @@ -import "@material/mwc-button"; import type { CSSResultGroup, TemplateResult } from "lit"; import { css, html, LitElement } from "lit"; import { customElement, property, state } from "lit/decorators"; @@ -6,6 +5,7 @@ import "../../../../../components/ha-card"; import "../../../../../components/ha-code-editor"; import "../../../../../components/ha-formfield"; import "../../../../../components/ha-switch"; +import "../../../../../components/ha-button"; import { getConfigEntries } from "../../../../../data/config_entries"; import { showOptionsFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-options-flow"; import "../../../../../layouts/hass-subpage"; @@ -79,10 +79,10 @@ export class BluetoothConfigDashboard extends LitElement { )} >
- ${this.hass.localize( "ui.panel.config.bluetooth.option_flow" - )}
@@ -100,11 +100,11 @@ export class BluetoothConfigDashboard extends LitElement {
+ > ${this.hass.localize( "ui.panel.config.bluetooth.advertisement_monitor" )} -
@@ -116,6 +116,15 @@ export class BluetoothConfigDashboard extends LitElement {
${this._renderConnectionAllocations()}
+
+ + ${this.hass.localize( + "ui.panel.config.bluetooth.connection_monitor" + )} + +
diff --git a/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-connection-monitor.ts b/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-connection-monitor.ts new file mode 100644 index 0000000000..1f9ed2f0e8 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-connection-monitor.ts @@ -0,0 +1,245 @@ +import type { UnsubscribeFunc } from "home-assistant-js-websocket"; +import type { CSSResultGroup, TemplateResult } from "lit"; +import { html, LitElement } from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { storage } from "../../../../../common/decorators/storage"; +import type { LocalizeFunc } from "../../../../../common/translations/localize"; +import type { DataTableColumnContainer } from "../../../../../components/data-table/ha-data-table"; +import "../../../../../components/ha-fab"; +import "../../../../../components/ha-icon-button"; +import "../../../../../components/ha-relative-time"; +import type { + BluetoothScannersDetails, + BluetoothConnectionData, + BluetoothAllocationsData, +} from "../../../../../data/bluetooth"; +import { + subscribeBluetoothScannersDetails, + subscribeBluetoothConnectionAllocations, + subscribeBluetoothAdvertisements, +} from "../../../../../data/bluetooth"; +import type { DeviceRegistryEntry } from "../../../../../data/device_registry"; +import "../../../../../layouts/hass-tabs-subpage-data-table"; +import { haStyle } from "../../../../../resources/styles"; +import type { HomeAssistant, Route } from "../../../../../types"; +import "../../../../../components/ha-metric"; + +@customElement("bluetooth-connection-monitor") +export class BluetoothConnectionMonitorPanel extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ attribute: false }) public route!: Route; + + @property({ type: Boolean }) public narrow = false; + + @property({ attribute: "is-wide", type: Boolean }) public isWide = false; + + @state() private _data: BluetoothConnectionData[] = []; + + @state() private _scanners: BluetoothScannersDetails = {}; + + @state() private _addressNames: Record = {}; + + @state() private _sourceDevices: Record = {}; + + @storage({ + key: "bluetooth-connection-table-grouping", + state: false, + subscribe: false, + }) + private _activeGrouping?: string = "source"; + + @storage({ + key: "bluetooth-connection-table-collapsed", + state: false, + subscribe: false, + }) + private _activeCollapsed: string[] = []; + + private _unsubConnectionAllocations?: (() => Promise) | undefined; + + private _unsubScanners?: UnsubscribeFunc; + + private _unsub_advertisements?: UnsubscribeFunc; + + @state() private _connectionAllocationData: Record< + string, + BluetoothAllocationsData + > = {}; + + public connectedCallback(): void { + super.connectedCallback(); + if (this.hass) { + this._unsubScanners = subscribeBluetoothScannersDetails( + this.hass.connection, + (scanners) => { + this._scanners = scanners; + } + ); + this._unsub_advertisements = subscribeBluetoothAdvertisements( + this.hass.connection, + (data) => { + for (const device of data) { + this._addressNames[device.address] = device.name; + } + } + ); + 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]; + }) + ); + this._subscribeBluetoothConnectionAllocations(); + } + } + + private async _subscribeBluetoothConnectionAllocations(): Promise { + if (this._unsubConnectionAllocations) { + return; + } + this._unsubConnectionAllocations = + await subscribeBluetoothConnectionAllocations( + this.hass.connection, + (data) => { + for (const allocation of data) { + this._connectionAllocationData[allocation.source] = allocation; + } + const newData: BluetoothConnectionData[] = []; + for (const allocation of Object.values( + this._connectionAllocationData + )) { + for (const address of allocation.allocated) { + newData.push({ + address: address, + source: allocation.source, + }); + } + } + this._data = newData; + } + ); + } + + public disconnectedCallback() { + super.disconnectedCallback(); + if (this._unsub_advertisements) { + this._unsub_advertisements(); + this._unsub_advertisements = undefined; + } + if (this._unsubConnectionAllocations) { + this._unsubConnectionAllocations(); + this._unsubConnectionAllocations = undefined; + } + if (this._unsubScanners) { + this._unsubScanners(); + this._unsubScanners = undefined; + } + } + + private _columns = memoizeOne( + (localize: LocalizeFunc): DataTableColumnContainer => { + const columns: DataTableColumnContainer = { + address: { + title: localize("ui.panel.config.bluetooth.address"), + sortable: true, + filterable: true, + showNarrow: true, + main: true, + hideable: false, + moveable: false, + direction: "asc", + flex: 1, + }, + name: { + title: localize("ui.panel.config.bluetooth.name"), + filterable: true, + sortable: true, + }, + device: { + title: localize("ui.panel.config.bluetooth.device"), + filterable: true, + sortable: true, + template: (data) => html`${data.device || "-"}`, + }, + source: { + title: localize("ui.panel.config.bluetooth.source"), + filterable: true, + sortable: true, + groupable: true, + }, + source_address: { + title: localize("ui.panel.config.bluetooth.source_address"), + filterable: true, + sortable: true, + defaultHidden: true, + }, + }; + + return columns; + } + ); + + private _dataWithNamedSourceAndIds = memoizeOne((data) => + data.map((row) => { + const device = this._sourceDevices[row.address]; + const scannerDevice = this._sourceDevices[row.source]; + const scanner = this._scanners[row.source]; + const name = this._addressNames[row.address] || row.address; + return { + ...row, + id: row.address, + name: name, + source_address: row.source, + source: + scannerDevice?.name_by_user || + scannerDevice?.name || + scanner?.name || + row.source, + device: device?.name_by_user || device?.name || undefined, + }; + }) + ); + + protected render(): TemplateResult { + return html` + + `; + } + + private _handleGroupingChanged(ev: CustomEvent) { + this._activeGrouping = ev.detail.value; + } + + private _handleCollapseChanged(ev: CustomEvent) { + this._activeCollapsed = ev.detail.value; + } + + static styles: CSSResultGroup = haStyle; +} + +declare global { + interface HTMLElementTagNameMap { + "bluetooth-connection-monitor": BluetoothConnectionMonitorPanel; + } +} diff --git a/src/translations/en.json b/src/translations/en.json index 57d2c33a97..fae9868f27 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -5356,7 +5356,9 @@ "advertisement_monitor_details": "The advertisement monitor listens for Bluetooth advertisements and displays the data in a structured format.", "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", "used_connection_slot_allocations": "Used connection slot allocations", + "no_connections": "No active connections", "no_connection_slot_allocations": "No connection slot allocations information available", "no_active_connection_support": "This adapter does not support making active (GATT) connections.", "address": "Address",