Initial bluetooth integration panel (#23531)

* Initial bluetooth device page

* Update src/panels/config/integrations/integration-panels/bluetooth/bluetooth-device-page.ts

Co-authored-by: J. Nick Koston <nick@koston.org>

* Update src/panels/config/integrations/integration-panels/bluetooth/bluetooth-device-page.ts

* Apply suggestions from code review

* Apply suggestions and implement dialog

* memoize

* Apply suggestions

* Clean up and fixes

* Adjust store usage

* Implement hassdialog

* Apply comments

---------

Co-authored-by: J. Nick Koston <nick@koston.org>
This commit is contained in:
Simon Lamon 2025-01-21 18:43:33 +01:00 committed by GitHub
parent 9e868e144d
commit ca2a9f9171
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 386 additions and 0 deletions

90
src/data/bluetooth.ts Normal file
View File

@ -0,0 +1,90 @@
import {
createCollection,
type Connection,
type UnsubscribeFunc,
} from "home-assistant-js-websocket";
import type { Store } from "home-assistant-js-websocket/dist/store";
import type { DataTableRowData } from "../components/data-table/ha-data-table";
export interface BluetoothDeviceData extends DataTableRowData {
address: string;
connectable: boolean;
manufacturer_data: Record<number, string>;
name: string;
rssi: number;
service_data: Record<string, string>;
service_uuids: string[];
source: string;
time: number;
tx_power: number;
}
interface BluetoothRemoveDeviceData {
address: string;
}
interface BluetoothAdvertisementSubscriptionMessage {
add?: BluetoothDeviceData[];
change?: BluetoothDeviceData[];
remove?: BluetoothRemoveDeviceData[];
}
const subscribeUpdates = (
conn: Connection,
store: Store<BluetoothDeviceData[]>
): Promise<UnsubscribeFunc> =>
conn.subscribeMessage<BluetoothAdvertisementSubscriptionMessage>(
(event) => {
const data = [...(store.state || [])];
if (event.add) {
for (const device_data of event.add) {
const index = data.findIndex(
(d) => d.address === device_data.address
);
if (index === -1) {
data.push(device_data);
} else {
data[index] = device_data;
}
}
}
if (event.change) {
for (const device_data of event.change) {
const index = data.findIndex(
(d) => d.address === device_data.address
);
if (index !== -1) {
data[index] = device_data;
}
}
}
if (event.remove) {
for (const device_data of event.remove) {
const index = data.findIndex(
(d) => d.address === device_data.address
);
if (index !== -1) {
data.splice(index, 1);
}
}
}
store.setState(data, true);
},
{
type: `bluetooth/subscribe_advertisements`,
}
);
export const subscribeBluetoothAdvertisements = (
conn: Connection,
onChange: (bluetoothDeviceData: BluetoothDeviceData[]) => void
) =>
createCollection<BluetoothDeviceData[]>(
"_bluetoothDeviceRows",
() => Promise.resolve<BluetoothDeviceData[]>([]), // empty array as initial state
subscribeUpdates,
conn,
onChange
);

View File

@ -10,6 +10,7 @@ export const integrationsWithPanel = {
thread: "config/thread",
zha: "config/zha/dashboard",
zwave_js: "config/zwave_js/dashboard",
bluetooth: "config/bluetooth",
};
export type IntegrationType =

View File

@ -548,6 +548,13 @@ class HaPanelConfig extends SubscribeMixin(HassRouterPage) {
"./integrations/integration-panels/thread/thread-config-panel"
),
},
bluetooth: {
tag: "bluetooth-device-page",
load: () =>
import(
"./integrations/integration-panels/bluetooth/bluetooth-config-panel"
),
},
application_credentials: {
tag: "ha-config-application-credentials",
load: () =>

View File

@ -0,0 +1,126 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { HASSDomEvent } from "../../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../../common/translations/localize";
import type {
DataTableColumnContainer,
RowClickedEvent,
} from "../../../../../components/data-table/ha-data-table";
import "../../../../../components/ha-fab";
import "../../../../../components/ha-icon-button";
import "../../../../../layouts/hass-tabs-subpage-data-table";
import { haStyle } from "../../../../../resources/styles";
import type { HomeAssistant, Route } from "../../../../../types";
import type { BluetoothDeviceData } from "../../../../../data/bluetooth";
import { subscribeBluetoothAdvertisements } from "../../../../../data/bluetooth";
import { showBluetoothDeviceInfoDialog } from "./show-dialog-bluetooth-device-info";
@customElement("bluetooth-config-panel")
export class BluetoothConfigPanel 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: BluetoothDeviceData[] = [];
private _unsub?: UnsubscribeFunc;
public connectedCallback(): void {
super.connectedCallback();
if (this.hass) {
this._unsub = subscribeBluetoothAdvertisements(
this.hass.connection,
(data) => {
this._data = data;
}
);
}
}
public disconnectedCallback() {
super.disconnectedCallback();
if (this._unsub) {
this._unsub();
this._unsub = undefined;
}
}
private _columns = memoizeOne(
(localize: LocalizeFunc): DataTableColumnContainer => {
const columns: DataTableColumnContainer<BluetoothDeviceData> = {
address: {
title: localize("ui.panel.config.bluetooth.address"),
sortable: true,
filterable: true,
showNarrow: true,
main: true,
hideable: false,
moveable: false,
direction: "asc",
flex: 2,
},
name: {
title: localize("ui.panel.config.bluetooth.name"),
filterable: true,
sortable: true,
},
source: {
title: localize("ui.panel.config.bluetooth.source"),
filterable: true,
sortable: true,
},
rssi: {
title: localize("ui.panel.config.bluetooth.rssi"),
type: "numeric",
sortable: true,
},
};
return columns;
}
);
private _dataWithIds = memoizeOne((data) =>
data.map((row) => ({
...row,
id: row.address,
}))
);
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._dataWithIds(this._data)}
@row-click=${this._handleRowClicked}
clickable
></hass-tabs-subpage-data-table>
`;
}
private _handleRowClicked(ev: HASSDomEvent<RowClickedEvent>) {
const entry = this._data.find((ent) => ent.address === ev.detail.id);
showBluetoothDeviceInfoDialog(this, {
entry: entry!,
});
}
static styles: CSSResultGroup = haStyle;
}
declare global {
interface HTMLElementTagNameMap {
"bluetooth-config-panel": BluetoothConfigPanel;
}
}

View File

@ -0,0 +1,126 @@
import type { TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import type { HassDialog } from "../../../../../dialogs/make-dialog-manager";
import { createCloseHeading } from "../../../../../components/ha-dialog";
import type { HomeAssistant } from "../../../../../types";
import type { BluetoothDeviceInfoDialogParams } from "./show-dialog-bluetooth-device-info";
import "../../../../../components/ha-button";
import { showToast } from "../../../../../util/toast";
import { copyToClipboard } from "../../../../../common/util/copy-clipboard";
@customElement("dialog-bluetooth-device-info")
class DialogBluetoothDeviceInfo extends LitElement implements HassDialog {
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: BluetoothDeviceInfoDialogParams;
public async showDialog(
params: BluetoothDeviceInfoDialogParams
): Promise<void> {
this._params = params;
}
public closeDialog(): boolean {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
return true;
}
public showDataAsHex(bytestring: string): string {
return Array.from(new TextEncoder().encode(bytestring))
.map((byte) => byte.toString(16).toUpperCase().padStart(2, "0"))
.join(" ");
}
private async _copyToClipboard(): Promise<void> {
if (!this._params) {
return;
}
await copyToClipboard(JSON.stringify(this._params!.entry));
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
protected render(): TemplateResult | typeof nothing {
if (!this._params) {
return nothing;
}
return html`
<ha-dialog
open
scrimClickAction
escapeKeyAction
@closed=${this.closeDialog}
.heading=${createCloseHeading(
this.hass,
this.hass.localize("ui.panel.config.bluetooth.device_information")
)}
>
<p>
<b>${this.hass.localize("ui.panel.config.bluetooth.address")}</b>:
${this._params.entry.address}
<br />
<b>${this.hass.localize("ui.panel.config.bluetooth.name")}</b>:
${this._params.entry.name}
<br />
<b>${this.hass.localize("ui.panel.config.bluetooth.source")}</b>:
${this._params.entry.source}
</p>
<h3>
${this.hass.localize("ui.panel.config.bluetooth.advertisement_data")}
</h3>
<h4>
${this.hass.localize("ui.panel.config.bluetooth.manufacturer_data")}
</h4>
<table width="100%">
<tbody>
${Object.entries(this._params.entry.manufacturer_data).map(
([key, value]) => html`
<tr>
${key}
</tr>
<tr>
${this.showDataAsHex(value)}
</tr>
`
)}
</tbody>
</table>
<h4>${this.hass.localize("ui.panel.config.bluetooth.service_data")}</h4>
<table width="100%">
<tbody>
${Object.entries(this._params.entry.service_data).map(
([key, value]) => html`
<tr>
${key}
</tr>
<tr>
${this.showDataAsHex(value)}
</tr>
`
)}
</tbody>
</table>
<ha-button slot="secondaryAction" @click=${this._copyToClipboard}
>${this.hass.localize(
"ui.panel.config.bluetooth.copy_to_clipboard"
)}</ha-button
>
</ha-dialog>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-bluetooth-device-info": DialogBluetoothDeviceInfo;
}
}

View File

@ -0,0 +1,20 @@
import { fireEvent } from "../../../../../common/dom/fire_event";
import type { BluetoothDeviceData } from "../../../../../data/bluetooth";
export interface BluetoothDeviceInfoDialogParams {
entry: BluetoothDeviceData;
}
export const loadBluetoothDeviceInfoDialog = () =>
import("./dialog-bluetooth-device-info");
export const showBluetoothDeviceInfoDialog = (
element: HTMLElement,
bluetoothDeviceInfoDialogParams: BluetoothDeviceInfoDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-bluetooth-device-info",
dialogImport: loadBluetoothDeviceInfoDialog,
dialogParams: bluetoothDeviceInfoDialogParams,
});
};

View File

@ -106,6 +106,10 @@ export const getMyRedirects = (): Redirects => ({
component: "matter",
redirect: "/config/matter/add",
},
config_bluetooth: {
component: "bluetooth",
redirect: "/config/bluetooth",
},
config_energy: {
component: "energy",
redirect: "/config/energy/dashboard",

View File

@ -5266,6 +5266,18 @@
"qos": "QoS",
"retain": "Retain"
},
"bluetooth": {
"title": "Bluetooth",
"address": "Address",
"name": "Name",
"source": "Source",
"rssi": "RSSI",
"device_information": "Device information",
"advertisement_data": "Advertisement data",
"manufacturer_data": "Manufacturer data",
"service_data": "Service data",
"copy_to_clipboard": "[%key:ui::panel::config::automation::editor::copy_to_clipboard%]"
},
"thread": {
"other_networks": "Other networks",
"my_network": "Preferred network",