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 {
@@ -116,6 +116,15 @@ export class BluetoothConfigDashboard extends LitElement {
${this._renderConnectionAllocations()}
+
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",