car_ui/screens/bluetooth.py
2026-01-08 02:54:12 +03:00

284 lines
9.8 KiB
Python

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 = ""
script_out = self._run_btctl_script(
[
"power on",
"discoverable-timeout 0",
"discoverable on",
"pairable on",
"agent NoInputNoOutput",
"default-agent",
]
)
if "Failed to set power on" in script_out or "org.bluez.Error" in script_out:
self.status.setText("Статус: питание BT выключено (проверьте rfkill)")
return
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 _run_btctl_script(self, commands: list[str]) -> str:
script = "\n".join(commands + ["quit"]) + "\n"
try:
result = subprocess.run(
["bluetoothctl"],
input=script,
capture_output=True,
text=True,
timeout=4,
check=False,
)
except (subprocess.SubprocessError, OSError):
self._last_error = "run-failed"
self._log(["bluetoothctl", "(script)"], "", "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(["bluetoothctl", "(script)"], 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)