diff --git a/src/data/bluetooth.ts b/src/data/bluetooth.ts new file mode 100644 index 0000000000..a9b2f082dd --- /dev/null +++ b/src/data/bluetooth.ts @@ -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; + name: string; + rssi: number; + service_data: Record; + 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 +): Promise => + conn.subscribeMessage( + (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( + "_bluetoothDeviceRows", + () => Promise.resolve([]), // empty array as initial state + + subscribeUpdates, + conn, + onChange + ); diff --git a/src/data/integration.ts b/src/data/integration.ts index 1cecc97b75..9104cddf21 100644 --- a/src/data/integration.ts +++ b/src/data/integration.ts @@ -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 = diff --git a/src/panels/config/ha-panel-config.ts b/src/panels/config/ha-panel-config.ts index 4bd03c7b37..aba0ef51ce 100644 --- a/src/panels/config/ha-panel-config.ts +++ b/src/panels/config/ha-panel-config.ts @@ -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: () => diff --git a/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-config-panel.ts b/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-config-panel.ts new file mode 100644 index 0000000000..978190e8a8 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/bluetooth/bluetooth-config-panel.ts @@ -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 = { + 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` + + `; + } + + private _handleRowClicked(ev: HASSDomEvent) { + 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; + } +} diff --git a/src/panels/config/integrations/integration-panels/bluetooth/dialog-bluetooth-device-info.ts b/src/panels/config/integrations/integration-panels/bluetooth/dialog-bluetooth-device-info.ts new file mode 100644 index 0000000000..2aeb28cb77 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/bluetooth/dialog-bluetooth-device-info.ts @@ -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 { + 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 { + 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` + +

+ ${this.hass.localize("ui.panel.config.bluetooth.address")}: + ${this._params.entry.address} +
+ ${this.hass.localize("ui.panel.config.bluetooth.name")}: + ${this._params.entry.name} +
+ ${this.hass.localize("ui.panel.config.bluetooth.source")}: + ${this._params.entry.source} +

+ +

+ ${this.hass.localize("ui.panel.config.bluetooth.advertisement_data")} +

+

+ ${this.hass.localize("ui.panel.config.bluetooth.manufacturer_data")} +

+ + + ${Object.entries(this._params.entry.manufacturer_data).map( + ([key, value]) => html` + + ${key} + + + ${this.showDataAsHex(value)} + + ` + )} + +
+ +

${this.hass.localize("ui.panel.config.bluetooth.service_data")}

+ + + ${Object.entries(this._params.entry.service_data).map( + ([key, value]) => html` + + ${key} + + + ${this.showDataAsHex(value)} + + ` + )} + +
+ + ${this.hass.localize( + "ui.panel.config.bluetooth.copy_to_clipboard" + )} +
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-bluetooth-device-info": DialogBluetoothDeviceInfo; + } +} diff --git a/src/panels/config/integrations/integration-panels/bluetooth/show-dialog-bluetooth-device-info.ts b/src/panels/config/integrations/integration-panels/bluetooth/show-dialog-bluetooth-device-info.ts new file mode 100644 index 0000000000..bfa4560260 --- /dev/null +++ b/src/panels/config/integrations/integration-panels/bluetooth/show-dialog-bluetooth-device-info.ts @@ -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, + }); +}; diff --git a/src/panels/my/ha-panel-my.ts b/src/panels/my/ha-panel-my.ts index e8c76e53a7..f8480455e4 100644 --- a/src/panels/my/ha-panel-my.ts +++ b/src/panels/my/ha-panel-my.ts @@ -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", diff --git a/src/translations/en.json b/src/translations/en.json index f21285eb73..4f1ebc349f 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -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",