From 4dc76de17c6e13d081b1d3924af38ad819b55f9d Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 8 Jan 2026 02:50:53 +0300 Subject: [PATCH] add bt --- qqqq | 2 + screens/bluetooth.py | 257 +++++++++++++++++++++++++++++++++++++++++++ screens/settings.py | 11 +- themes/day.py | 29 +++++ themes/night.py | 27 +++++ 5 files changed, 325 insertions(+), 1 deletion(-) create mode 100644 screens/bluetooth.py diff --git a/qqqq b/qqqq index 0cca390..a5b5e8b 100644 --- a/qqqq +++ b/qqqq @@ -1,3 +1,5 @@ export DISPLAY=:0 ./.venv/bin/python3 ./main.py + +sudo usermod -aG bluetooth cheykrym diff --git a/screens/bluetooth.py b/screens/bluetooth.py new file mode 100644 index 0000000..ccfed0e --- /dev/null +++ b/screens/bluetooth.py @@ -0,0 +1,257 @@ +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) diff --git a/screens/settings.py b/screens/settings.py index 04af781..a6c96c1 100644 --- a/screens/settings.py +++ b/screens/settings.py @@ -12,6 +12,7 @@ from PySide6.QtWidgets import ( from PySide6.QtCore import Qt from PySide6.QtGui import QFont from PySide6.QtWidgets import QScroller +from screens.bluetooth import BluetoothScreen class SettingsRow(QPushButton): @@ -90,13 +91,14 @@ class SettingsScreen(QWidget): content_layout.setContentsMargins(0, 0, 0, 0) content_layout.setSpacing(12) - self._add_section( + bt_row = self._add_section( content_layout, "Сеть", [ ("Wi-Fi", "Доступные сети и подключение"), ("Bluetooth", "Сопряжение и устройства"), ], + return_row_title="Bluetooth", ) dev_row = self._add_section( @@ -123,11 +125,15 @@ class SettingsScreen(QWidget): list_layout.addWidget(scroll, 1) self._dev_screen = self._build_dev_screen() + self._bt_screen = BluetoothScreen(self._show_list) self.stack.addWidget(self._list_screen) self.stack.addWidget(self._dev_screen) + self.stack.addWidget(self._bt_screen) if dev_row is not None: dev_row.clicked.connect(self._show_dev) + if bt_row is not None: + bt_row.clicked.connect(self._show_bluetooth) def _build_dev_screen(self) -> QWidget: screen = QWidget() @@ -188,6 +194,9 @@ class SettingsScreen(QWidget): def _show_list(self): self.stack.setCurrentWidget(self._list_screen) + def _show_bluetooth(self): + self.stack.setCurrentWidget(self._bt_screen) + def _exit_app(self): app = QApplication.instance() if app is not None: diff --git a/themes/day.py b/themes/day.py index 6c8b6cf..fd2e51b 100644 --- a/themes/day.py +++ b/themes/day.py @@ -29,4 +29,33 @@ QWidget { background: #F4F6F8; color: #111827; } font-weight: 600; } #DevExitBtn:hover { background: #E5E7EB; } + +#BluetoothStatus { color: rgba(107,114,128,0.95); } +#BluetoothList { + background: #FFFFFF; + border: 1px solid #E5E7EB; + border-radius: 12px; + padding: 6px; +} +#BluetoothList::item { + padding: 12px 10px; + border-radius: 10px; +} +#BluetoothList::item:selected { background: #F3F4F6; } +#BluetoothActionBtn { + background: #FFFFFF; + border-radius: 12px; + border: 1px solid #E5E7EB; + font-size: 14px; + font-weight: 600; +} +#BluetoothActionBtn:hover { background: #F9FAFB; } +#BluetoothActionBtnPrimary { + background: #E5E7EB; + border-radius: 12px; + border: 1px solid #D1D5DB; + font-size: 14px; + font-weight: 600; +} +#BluetoothActionBtnPrimary:hover { background: #D1D5DB; } """ diff --git a/themes/night.py b/themes/night.py index 306f2ec..f1d2845 100644 --- a/themes/night.py +++ b/themes/night.py @@ -26,4 +26,31 @@ QWidget { background: #0B0E11; color: #E6EAF0; } font-weight: 600; } #DevExitBtn:hover { background: #263142; } + +#BluetoothStatus { color: rgba(138,147,166,0.95); } +#BluetoothList { + background: #0F1318; + border: 1px solid #1B2330; + border-radius: 12px; + padding: 6px; +} +#BluetoothList::item { + padding: 12px 10px; + border-radius: 10px; +} +#BluetoothList::item:selected { background: #1B2330; } +#BluetoothActionBtn { + background: #141A22; + border-radius: 12px; + font-size: 14px; + font-weight: 600; +} +#BluetoothActionBtn:hover { background: #1B2330; } +#BluetoothActionBtnPrimary { + background: #2A3A52; + border-radius: 12px; + font-size: 14px; + font-weight: 600; +} +#BluetoothActionBtnPrimary:hover { background: #344968; } """