Add panel to show Bluetooth connection overview (#24463)

* Add panel to show Bluetooth connection overview

* tweak

* migrate to ha-button
This commit is contained in:
J. Nick Koston 2025-03-04 07:29:15 -10:00 committed by GitHub
parent e5b460c259
commit 61effc3f70
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 270 additions and 5 deletions

View File

@ -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;

View File

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

View File

@ -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 {
)}
>
<div class="card-actions">
<mwc-button @click=${this._openOptionFlow}
<ha-button @click=${this._openOptionFlow}
>${this.hass.localize(
"ui.panel.config.bluetooth.option_flow"
)}</mwc-button
)}</ha-button
>
</div>
</ha-card>
@ -100,11 +100,11 @@ export class BluetoothConfigDashboard extends LitElement {
</div>
<div class="card-actions">
<a href="/config/bluetooth/advertisement-monitor"
><mwc-button>
><ha-button>
${this.hass.localize(
"ui.panel.config.bluetooth.advertisement_monitor"
)}
</mwc-button></a
</ha-button></a
>
</div>
</ha-card>
@ -116,6 +116,15 @@ export class BluetoothConfigDashboard extends LitElement {
<div class="card-content">
${this._renderConnectionAllocations()}
</div>
<div class="card-actions">
<a href="/config/bluetooth/connection-monitor"
><ha-button>
${this.hass.localize(
"ui.panel.config.bluetooth.connection_monitor"
)}
</ha-button></a
>
</div>
</ha-card>
</div>
</hass-subpage>

View File

@ -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<string, string> = {};
@state() private _sourceDevices: Record<string, DeviceRegistryEntry> = {};
@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<void>) | 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<void> {
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<BluetoothConnectionData> = {
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`
<hass-tabs-subpage-data-table
.hass=${this.hass}
.narrow=${this.narrow}
.route=${this.route}
.columns=${this._columns(this.hass.localize)}
.data=${this._dataWithNamedSourceAndIds(this._data)}
.initialGroupColumn=${this._activeGrouping}
.initialCollapsedGroups=${this._activeCollapsed}
.noDataText=${this.hass.localize(
"ui.panel.config.bluetooth.no_connections"
)}
@grouping-changed=${this._handleGroupingChanged}
@collapsed-changed=${this._handleCollapseChanged}
></hass-tabs-subpage-data-table>
`;
}
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;
}
}

View File

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