from __future__ import annotations import os import subprocess from dataclasses import dataclass from datetime import datetime import re from PySide6.QtCore import Qt, QTimer, QSettings from PySide6.QtGui import QFont from PySide6.QtWidgets import ( QWidget, QLabel, QVBoxLayout, QHBoxLayout, QPushButton, QListWidget, QListWidgetItem, QScroller, ) @dataclass class BluetoothDevice: mac: str name: str class BluetoothScreen(QWidget): def __init__(self, on_back): super().__init__() self._on_back = on_back self._settings = QSettings("car_ui", "ui") self._log_path = os.path.expanduser("~/.cache/car_ui/bluetooth.log") self._last_error = "" root = QVBoxLayout(self) root.setContentsMargins(0, 0, 0, 0) root.setSpacing(12) hdr = QHBoxLayout() hdr.setContentsMargins(0, 0, 0, 0) hdr.setSpacing(12) back_btn = QPushButton("Назад") back_btn.setObjectName("SettingsBackBtn") back_btn.setMinimumSize(120, 44) back_btn.clicked.connect(self._on_back) title = QLabel("Bluetooth") title.setFont(QFont("", 22, 700)) hdr.addWidget(back_btn) hdr.addWidget(title) hdr.addStretch(1) self.status = QLabel("Статус: —") self.status.setObjectName("BluetoothStatus") self.status.setFont(QFont("", 12)) self.list = QListWidget() self.list.setObjectName("BluetoothList") self.list.setSpacing(6) self.list.setSelectionMode(QListWidget.SingleSelection) self.list.itemSelectionChanged.connect(self._on_select) QScroller.scroller(self.list.viewport()).grabGesture( self.list.viewport(), QScroller.LeftMouseButtonGesture, ) actions = QHBoxLayout() actions.setContentsMargins(0, 0, 0, 0) actions.setSpacing(12) self.btn_visible = QPushButton("Сделать видимым") self.btn_visible.setObjectName("BluetoothActionBtn") self.btn_visible.setMinimumHeight(56) self.btn_visible.clicked.connect(self._make_visible) self.btn_refresh = QPushButton("Обновить список") self.btn_refresh.setObjectName("BluetoothActionBtn") self.btn_refresh.setMinimumHeight(56) self.btn_refresh.clicked.connect(self.refresh_list) self.btn_connect = QPushButton("Подключить") self.btn_connect.setObjectName("BluetoothActionBtnPrimary") self.btn_connect.setMinimumHeight(56) self.btn_connect.setEnabled(False) self.btn_connect.clicked.connect(self._connect_selected) self.btn_disconnect = QPushButton("Отключить") self.btn_disconnect.setObjectName("BluetoothActionBtn") self.btn_disconnect.setMinimumHeight(56) self.btn_disconnect.setEnabled(False) self.btn_disconnect.clicked.connect(self._disconnect_selected) actions.addWidget(self.btn_visible, 1) actions.addWidget(self.btn_refresh, 1) actions.addWidget(self.btn_connect, 1) actions.addWidget(self.btn_disconnect, 1) root.addLayout(hdr) root.addWidget(self.status) root.addWidget(self.list, 1) root.addLayout(actions) self.refresh_list() QTimer.singleShot(200, self._auto_connect_last) def refresh_list(self): devices = self._paired_devices() self.list.clear() for dev in devices: label = f"{dev.name} ({dev.mac})" if dev.name else dev.mac item = QListWidgetItem(label) item.setData(Qt.UserRole, dev.mac) self.list.addItem(item) self._update_status() def _on_select(self): has_selection = self._selected_mac() is not None self.btn_connect.setEnabled(has_selection) self.btn_disconnect.setEnabled(has_selection) self._update_status() def _selected_mac(self) -> str | None: items = self.list.selectedItems() if not items: return None return items[0].data(Qt.UserRole) def _auto_connect_last(self): last_mac = self._settings.value("bluetooth/last_mac", "") if not last_mac: return self._connect_device(last_mac, silent=True) def _make_visible(self): self._last_error = "" self._run_cmd(["bluetoothctl", "power", "on"]) if self._last_error: self.status.setText("Статус: питание BT выключено (проверьте rfkill)") return self._run_cmd(["bluetoothctl", "discoverable-timeout", "0"]) self._run_cmd(["bluetoothctl", "discoverable", "on"]) self._run_cmd(["bluetoothctl", "pairable", "on"]) self._run_cmd(["bluetoothctl", "agent", "NoInputNoOutput"]) self._run_cmd(["bluetoothctl", "default-agent"]) if self._last_error: self.status.setText(f"Статус: ошибка ({self._last_error})") else: self.status.setText("Статус: видим для сопряжения") def _connect_selected(self): mac = self._selected_mac() if not mac: return self._connect_device(mac, silent=False) def _connect_device(self, mac: str, silent: bool): self._last_error = "" self._run_cmd(["bluetoothctl", "trust", mac]) self._run_cmd(["bluetoothctl", "connect", mac]) if not silent: if self._last_error: self.status.setText(f"Статус: ошибка ({self._last_error})") else: self.status.setText(f"Статус: подключаемся к {mac}") self._settings.setValue("bluetooth/last_mac", mac) QTimer.singleShot(300, self._update_status) def _disconnect_selected(self): mac = self._selected_mac() if not mac: return self._last_error = "" self._run_cmd(["bluetoothctl", "disconnect", mac]) if self._last_error: self.status.setText(f"Статус: ошибка ({self._last_error})") else: self.status.setText(f"Статус: отключено от {mac}") QTimer.singleShot(300, self._update_status) def _update_status(self): mac = self._selected_mac() if not mac: self.status.setText("Статус: выберите устройство") return info = self._device_info(mac) connected = info.get("Connected", "no") == "yes" name = info.get("Name", mac) state = "подключено" if connected else "не подключено" self.status.setText(f"Статус: {name} — {state}") def _device_info(self, mac: str) -> dict[str, str]: out = self._run_cmd(["bluetoothctl", "info", mac]) info: dict[str, str] = {} for line in out.splitlines(): if ":" not in line: continue key, value = line.split(":", 1) info[key.strip()] = value.strip() return info def _paired_devices(self) -> list[BluetoothDevice]: out = self._run_cmd(["bluetoothctl", "devices", "Paired"]) if "Invalid command" in out or not out: out = self._run_cmd(["bluetoothctl", "paired-devices"]) if "Invalid command" in out or not out: out = self._run_cmd(["bluetoothctl", "devices"]) devices: list[BluetoothDevice] = [] for line in self._strip_ansi(out).splitlines(): parts = line.split() if len(parts) >= 2 and parts[0] == "Device": mac = parts[1] name = " ".join(parts[2:]) if len(parts) > 2 else "" devices.append(BluetoothDevice(mac=mac, name=name)) return devices def _run_cmd(self, args: list[str]) -> str: try: result = subprocess.run( args, capture_output=True, text=True, timeout=2, check=False, ) except (subprocess.SubprocessError, OSError): self._last_error = "run-failed" self._log(args, "", "run-failed", 1) return "" stdout = result.stdout.strip() stderr = result.stderr.strip() if result.returncode != 0 or stderr: self._last_error = stderr or f"exit={result.returncode}" self._log(args, stdout, stderr, result.returncode) return stdout def _log(self, args: list[str], stdout: str, stderr: str, code: int): try: os.makedirs(os.path.dirname(self._log_path), exist_ok=True) ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") line = ( f"[{ts}] cmd={' '.join(args)} code={code} " f"stdout={stdout!r} stderr={stderr!r}\n" ) with open(self._log_path, "a", encoding="utf-8") as f: f.write(line) except OSError: pass def _strip_ansi(self, text: str) -> str: return re.sub(r"\x1b\[[0-9;]*m", "", text)