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 28874a5ae3d6..246b6ff20207 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,21 +1,14 @@ +import { mdiCogOutline } from "@mdi/js"; import type { CSSResultGroup, TemplateResult } from "lit"; -import { css, html, LitElement, nothing } from "lit"; +import { LitElement, css, html, nothing } from "lit"; import { customElement, property, state } from "lit/decorators"; -import "../../../../../components/ha-card"; -import "../../../../../components/ha-code-editor"; -import "../../../../../components/ha-formfield"; -import "../../../../../components/ha-switch"; +import "../../../../../components/ha-alert"; import "../../../../../components/ha-button"; -import { getConfigEntries } from "../../../../../data/config_entries"; -import { showOptionsFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-options-flow"; -import "../../../../../layouts/hass-subpage"; -import { haStyle } from "../../../../../resources/styles"; -import type { HomeAssistant } from "../../../../../types"; -import { - subscribeBluetoothConnectionAllocations, - subscribeBluetoothScannerState, - subscribeBluetoothScannersDetails, -} from "../../../../../data/bluetooth"; +import "../../../../../components/ha-card"; +import "../../../../../components/ha-icon-button"; +import "../../../../../components/ha-list"; +import "../../../../../components/ha-list-item"; + import type { BluetoothAllocationsData, BluetoothScannerState, @@ -23,10 +16,17 @@ import type { HaScannerType, } from "../../../../../data/bluetooth"; import { - getValueInPercentage, - roundWithOneDecimal, -} from "../../../../../util/calculate"; -import "../../../../../components/ha-metric"; + subscribeBluetoothConnectionAllocations, + subscribeBluetoothScannerState, + subscribeBluetoothScannersDetails, +} from "../../../../../data/bluetooth"; +import type { ConfigEntry } from "../../../../../data/config_entries"; +import { getConfigEntries } from "../../../../../data/config_entries"; +import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry"; +import { showOptionsFlowDialog } from "../../../../../dialogs/config-flow/show-dialog-options-flow"; +import "../../../../../layouts/hass-subpage"; +import { haStyle } from "../../../../../resources/styles"; +import type { HomeAssistant } from "../../../../../types"; @customElement("bluetooth-config-dashboard") export class BluetoothConfigDashboard extends LitElement { @@ -34,18 +34,14 @@ export class BluetoothConfigDashboard extends LitElement { @property({ type: Boolean }) public narrow = false; - @state() private _connectionAllocationData: BluetoothAllocationsData[] = []; + @state() private _configEntries: ConfigEntry[] = []; - @state() private _connectionAllocationsError?: string; + @state() private _connectionAllocationData: BluetoothAllocationsData[] = []; - @state() private _scannerState?: BluetoothScannerState; + @state() private _scannerStates: Record = {}; @state() private _scannerDetails?: BluetoothScannersDetails; - private _configEntry = new URLSearchParams(window.location.search).get( - "config_entry" - ); - private _unsubConnectionAllocations?: (() => Promise) | undefined; private _unsubScannerState?: (() => Promise) | undefined; @@ -55,41 +51,44 @@ export class BluetoothConfigDashboard extends LitElement { public connectedCallback(): void { super.connectedCallback(); if (this.hass) { + this._loadConfigEntries(); this._subscribeBluetoothConnectionAllocations(); this._subscribeBluetoothScannerState(); this._subscribeScannerDetails(); } } + private async _loadConfigEntries(): Promise { + this._configEntries = await getConfigEntries(this.hass, { + domain: "bluetooth", + }); + } + private async _subscribeBluetoothConnectionAllocations(): Promise { - if (this._unsubConnectionAllocations || !this._configEntry) { + if (this._unsubConnectionAllocations) { return; } - try { - this._unsubConnectionAllocations = - await subscribeBluetoothConnectionAllocations( - this.hass.connection, - (data) => { - this._connectionAllocationData = data; - }, - this._configEntry - ); - } catch (err: any) { - this._unsubConnectionAllocations = undefined; - this._connectionAllocationsError = err.message; - } + this._unsubConnectionAllocations = + await subscribeBluetoothConnectionAllocations( + this.hass.connection, + (data) => { + this._connectionAllocationData = data; + } + ); } private async _subscribeBluetoothScannerState(): Promise { - if (this._unsubScannerState || !this._configEntry) { + if (this._unsubScannerState) { return; } this._unsubScannerState = await subscribeBluetoothScannerState( this.hass.connection, (scannerState) => { - this._scannerState = scannerState; - }, - this._configEntry + this._scannerStates = { + ...this._scannerStates, + [scannerState.source]: scannerState, + }; + } ); } @@ -122,31 +121,19 @@ export class BluetoothConfigDashboard extends LitElement { } protected render(): TemplateResult { - // Get scanner type to determine if options button should be shown - const scannerDetails = - this._scannerState && this._scannerDetails?.[this._scannerState.source]; - const scannerType: HaScannerType = - scannerDetails?.scanner_type ?? "unknown"; - const isRemoteScanner = scannerType === "remote"; - return html` - +
-
${this._renderScannerState()}
- ${!isRemoteScanner - ? html`
- ${this.hass.localize( - "ui.panel.config.bluetooth.option_flow" - )} -
` - : nothing} + ${this._renderAdaptersList()}
- ${this._renderConnectionAllocations()} +

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

- roundWithOneDecimal(getValueInPercentage(used, 0, total)); + private _renderAdaptersList() { + if (this._configEntries.length === 0) { + return html` + ${this.hass.localize( + "ui.panel.config.bluetooth.no_scanner_state_available" + )} + `; + } + + // Build source to device mapping (same as visualization) + const sourceDevices: Record = {}; + Object.values(this.hass.devices).forEach((device) => { + const btConnection = device.connections.find( + (connection) => connection[0] === "bluetooth" + ); + if (btConnection) { + sourceDevices[btConnection[1]] = device; + } + }); + + return this._configEntries.map((entry) => { + // Find scanner by matching device's config_entries to this entry + const scannerDetails = this._scannerDetails + ? Object.values(this._scannerDetails).find((d) => { + const device = sourceDevices[d.source]; + return device?.config_entries.includes(entry.entry_id); + }) + : undefined; + const scannerState = scannerDetails + ? this._scannerStates[scannerDetails.source] + : undefined; + const scannerType: HaScannerType = + scannerDetails?.scanner_type ?? "unknown"; + const isRemoteScanner = scannerType === "remote"; + const hasMismatch = + scannerState && + scannerState.current_mode !== scannerState.requested_mode; + + // Find allocation data for this scanner + const allocations = scannerDetails + ? this._connectionAllocationData.find( + (a) => a.source === scannerDetails.source + ) + : undefined; + + const secondaryText = this._formatScannerModeText(scannerState); + + return html` + + ${entry.title} + + ${secondaryText}${allocations + ? allocations.slots > 0 + ? ` · ${allocations.slots - allocations.free}/${allocations.slots} ${this.hass.localize("ui.panel.config.bluetooth.active_connections")}` + : ` · ${this.hass.localize("ui.panel.config.bluetooth.no_connection_slots")}` + : nothing} + + ${!isRemoteScanner + ? html`` + : nothing} + + ${hasMismatch && scannerDetails + ? this._renderScannerMismatchWarning( + entry.title, + scannerState, + scannerType + ) + : nothing} + `; + }); + } private _renderScannerMismatchWarning( + name: string, scannerState: BluetoothScannerState, - scannerType: HaScannerType, - formatMode: (mode: string | null) => string + scannerType: HaScannerType ) { const instructions: string[] = []; @@ -238,8 +306,9 @@ export class BluetoothConfigDashboard extends LitElement { ${this.hass.localize( "ui.panel.config.bluetooth.scanner_mode_mismatch", { - requested: formatMode(scannerState.requested_mode), - current: formatMode(scannerState.current_mode), + name: name, + requested: this._formatMode(scannerState.requested_mode), + current: this._formatMode(scannerState.current_mode), } )}
@@ -249,127 +318,59 @@ export class BluetoothConfigDashboard extends LitElement { `; } - private _renderScannerState() { - if (!this._configEntry || !this._scannerState) { - return html`
- ${this.hass.localize( - "ui.panel.config.bluetooth.no_scanner_state_available" - )} -
`; + private _formatMode(mode: string | null): string { + switch (mode) { + case null: + return this.hass.localize( + "ui.panel.config.bluetooth.scanning_mode_none" + ); + case "active": + return this.hass.localize( + "ui.panel.config.bluetooth.scanning_mode_active" + ); + case "passive": + return this.hass.localize( + "ui.panel.config.bluetooth.scanning_mode_passive" + ); + default: + return mode; } - - const scannerState = this._scannerState; - // Find the scanner details for this source - const scannerDetails = this._scannerDetails?.[scannerState.source]; - const scannerType: HaScannerType = - scannerDetails?.scanner_type ?? "unknown"; - - const formatMode = (mode: string | null) => { - switch (mode) { - case null: - return this.hass.localize( - "ui.panel.config.bluetooth.scanning_mode_none" - ); - case "active": - return this.hass.localize( - "ui.panel.config.bluetooth.scanning_mode_active" - ); - case "passive": - return this.hass.localize( - "ui.panel.config.bluetooth.scanning_mode_passive" - ); - default: - return mode; // Fallback for unknown modes - } - }; - - return html` -
-
- ${this.hass.localize( - "ui.panel.config.bluetooth.current_scanning_mode" - )}: - ${formatMode(scannerState.current_mode)} -
-
- ${this.hass.localize( - "ui.panel.config.bluetooth.requested_scanning_mode" - )}: - ${formatMode(scannerState.requested_mode)} -
- ${scannerState.current_mode !== scannerState.requested_mode - ? this._renderScannerMismatchWarning( - scannerState, - scannerType, - formatMode - ) - : nothing} -
- `; } - private _renderConnectionAllocations() { - if (this._connectionAllocationsError) { - return html`${this._connectionAllocationsError}`; - } - if (this._connectionAllocationData.length === 0) { - return html`
- ${this.hass.localize( - "ui.panel.config.bluetooth.no_connection_slot_allocations" - )} -
`; - } - const allocations = this._connectionAllocationData[0]; - const allocationsUsed = allocations.slots - allocations.free; - const allocationsTotal = allocations.slots; - if (allocationsTotal === 0) { - return html`
- ${this.hass.localize( - "ui.panel.config.bluetooth.no_active_connection_support" - )} -
`; + private _formatModeLabel(mode: string | null): string { + switch (mode) { + case null: + return this.hass.localize( + "ui.panel.config.bluetooth.scanning_mode_none_label" + ); + case "active": + return this.hass.localize( + "ui.panel.config.bluetooth.scanning_mode_active_label" + ); + case "passive": + return this.hass.localize( + "ui.panel.config.bluetooth.scanning_mode_passive_label" + ); + default: + return mode; } - return html` -

- ${this.hass.localize( - "ui.panel.config.bluetooth.connection_slot_allocations_monitor_details", - { slots: allocationsTotal } - )} -

- 0 - ? `${allocationsUsed}/${allocationsTotal} (${allocations.allocated.join(", ")})` - : `${allocationsUsed}/${allocationsTotal}`} - > - `; } - private async _openOptionFlow() { - const configEntryId = this._configEntry; - if (!configEntryId) { - return; + private _formatScannerModeText( + scannerState: BluetoothScannerState | undefined + ): string { + if (!scannerState) { + return this.hass.localize( + "ui.panel.config.bluetooth.scanner_state_unknown" + ); } - const configEntries = await getConfigEntries(this.hass, { - domain: "bluetooth", - }); - const configEntry = configEntries.find( - (entry) => entry.entry_id === configEntryId - ); - showOptionsFlowDialog(this, configEntry!); + + return this._formatModeLabel(scannerState.current_mode); + } + + private _openOptionFlow(ev: Event) { + const button = ev.currentTarget as HTMLElement & { entry: ConfigEntry }; + showOptionsFlowDialog(this, button.entry); } static get styles(): CSSResultGroup { @@ -394,17 +395,9 @@ export class BluetoothConfigDashboard extends LitElement { display: flex; justify-content: flex-end; } - .scanner-state { - margin-bottom: 16px; - } - .state-row { - display: flex; - justify-content: space-between; - align-items: center; - padding: 4px 0; - } - .state-value { - font-weight: 500; + ha-list-item { + --mdc-list-item-meta-display: flex; + --mdc-list-item-meta-size: 48px; } `, ]; diff --git a/src/translations/en.json b/src/translations/en.json index 8529b8056223..1718e3a1a850 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -6014,26 +6014,31 @@ }, "bluetooth": { "title": "Bluetooth", - "settings_title": "Bluetooth settings", + "settings_title": "Bluetooth adapters", "option_flow": "Configure Bluetooth options", "advertisement_monitor": "Advertisement monitor", "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_slot_allocations_monitor_description": "The connection slot allocations monitor displays the (GATT) connection slot allocations for each adapter. 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", "visualization": "Visualization", "used_connection_slot_allocations": "Used connection slot allocations", "no_connections": "No active connections", + "active_connections": "connections", "no_advertisements_found": "No matching Bluetooth advertisements found", "no_connection_slot_allocations": "No connection slot allocations information available", - "no_active_connection_support": "This adapter does not support making active (GATT) connections.", + "no_connection_slots": "No connection slots", "no_scanner_state_available": "No scanner state available", + "scanner_state_unknown": "State unknown", "current_scanning_mode": "Current scanning mode", "requested_scanning_mode": "Requested scanning mode", "scanning_mode_none": "none", "scanning_mode_active": "active", "scanning_mode_passive": "passive", - "scanner_mode_mismatch": "Scanner requested {requested} mode but is operating in {current} mode. The scanner is in a bad state and needs to be power cycled.", + "scanning_mode_active_label": "Active scanning", + "scanning_mode_passive_label": "Passive scanning", + "scanning_mode_none_label": "No scanning", + "scanner_mode_mismatch": "{name} requested {requested} mode but is operating in {current} mode. The scanner is in a bad state and needs to be power cycled.", "scanner_mode_mismatch_remote": "For proxies: reboot the device", "scanner_mode_mismatch_usb": "For USB adapters: unplug and plug back in", "scanner_mode_mismatch_uart": "For UART/onboard adapters: power down the system completely and power it back up",