mirror of
https://github.com/home-assistant/frontend.git
synced 2025-07-28 11:46:42 +00:00
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:
parent
9e868e144d
commit
ca2a9f9171
90
src/data/bluetooth.ts
Normal file
90
src/data/bluetooth.ts
Normal 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
|
||||
);
|
@ -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 =
|
||||
|
@ -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: () =>
|
||||
|
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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,
|
||||
});
|
||||
};
|
@ -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",
|
||||
|
@ -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",
|
||||
|
Loading…
x
Reference in New Issue
Block a user