bluetooth service
This commit is contained in:
parent
299314369f
commit
36fbd1034f
110
screens/media.py
110
screens/media.py
@ -1,5 +1,3 @@
|
|||||||
import subprocess
|
|
||||||
|
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QWidget,
|
QWidget,
|
||||||
QLabel,
|
QLabel,
|
||||||
@ -12,12 +10,16 @@ from PySide6.QtWidgets import (
|
|||||||
from PySide6.QtCore import Qt, QSize, QTimer, Signal
|
from PySide6.QtCore import Qt, QSize, QTimer, Signal
|
||||||
from PySide6.QtGui import QFont
|
from PySide6.QtGui import QFont
|
||||||
|
|
||||||
|
from services.bluetooth_service import BluetoothService
|
||||||
|
|
||||||
|
|
||||||
class MediaScreen(QWidget):
|
class MediaScreen(QWidget):
|
||||||
source_changed = Signal(str)
|
source_changed = Signal(str)
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
|
self._bt_service = BluetoothService(self)
|
||||||
|
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
root.setContentsMargins(18, 16, 18, 16)
|
root.setContentsMargins(18, 16, 18, 16)
|
||||||
root.setSpacing(14)
|
root.setSpacing(14)
|
||||||
@ -154,97 +156,45 @@ class MediaScreen(QWidget):
|
|||||||
self._refresh_metadata()
|
self._refresh_metadata()
|
||||||
|
|
||||||
def _toggle_play(self):
|
def _toggle_play(self):
|
||||||
status = self._player_status()
|
"""Переключить воспроизведение/пауза."""
|
||||||
if status == "playing":
|
self._bt_service.player_toggle()
|
||||||
self._run_btctl(["menu player", "pause"])
|
|
||||||
else:
|
|
||||||
self._run_btctl(["menu player", "play"])
|
|
||||||
QTimer.singleShot(300, self._refresh_metadata)
|
QTimer.singleShot(300, self._refresh_metadata)
|
||||||
|
|
||||||
def _prev(self):
|
def _prev(self):
|
||||||
self._run_btctl(["menu player", "previous"])
|
"""Предыдущий трек."""
|
||||||
|
self._bt_service.player_previous()
|
||||||
QTimer.singleShot(300, self._refresh_metadata)
|
QTimer.singleShot(300, self._refresh_metadata)
|
||||||
|
|
||||||
def _next(self):
|
def _next(self):
|
||||||
self._run_btctl(["menu player", "next"])
|
"""Следующий трек."""
|
||||||
|
self._bt_service.player_next()
|
||||||
QTimer.singleShot(300, self._refresh_metadata)
|
QTimer.singleShot(300, self._refresh_metadata)
|
||||||
|
|
||||||
def _player_status(self) -> str | None:
|
|
||||||
out = self._run_btctl(["menu player", "show"])
|
|
||||||
for line in out.splitlines():
|
|
||||||
if "Status:" in line:
|
|
||||||
return line.split("Status:", 1)[1].strip()
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _refresh_metadata(self):
|
def _refresh_metadata(self):
|
||||||
out = self._run_btctl(["menu player", "show"])
|
"""Обновить метаданные трека."""
|
||||||
title = None
|
metadata = self._bt_service.get_player_metadata()
|
||||||
artist = None
|
|
||||||
album = None
|
if metadata["title"]:
|
||||||
source = None
|
self.title.setText(metadata["title"])
|
||||||
position = None
|
if metadata["artist"]:
|
||||||
duration = None
|
self.artist.setText(metadata["artist"])
|
||||||
status = None
|
if metadata["album"]:
|
||||||
for line in out.splitlines():
|
self.album.setText(metadata["album"])
|
||||||
if "Name:" in line:
|
if metadata["source"]:
|
||||||
source = line.split("Name:", 1)[1].strip()
|
text = f"Источник: {metadata["source"]}"
|
||||||
if "Track.Title:" in line:
|
|
||||||
title = line.split("Track.Title:", 1)[1].strip()
|
|
||||||
if "Track.Artist:" in line:
|
|
||||||
artist = line.split("Track.Artist:", 1)[1].strip()
|
|
||||||
if "Track.Album:" in line:
|
|
||||||
album = line.split("Track.Album:", 1)[1].strip()
|
|
||||||
if "Position:" in line:
|
|
||||||
position = self._parse_hex_value(line)
|
|
||||||
if "Track.Duration:" in line:
|
|
||||||
duration = self._parse_hex_value(line)
|
|
||||||
if "Status:" in line:
|
|
||||||
status = line.split("Status:", 1)[1].strip()
|
|
||||||
if title:
|
|
||||||
self.title.setText(title)
|
|
||||||
if artist:
|
|
||||||
self.artist.setText(artist)
|
|
||||||
if album:
|
|
||||||
self.album.setText(album)
|
|
||||||
if source:
|
|
||||||
text = f"Источник: {source}"
|
|
||||||
self.source.setText(text)
|
self.source.setText(text)
|
||||||
self.source_changed.emit(text)
|
self.source_changed.emit(text)
|
||||||
if duration is not None and duration > 0:
|
if metadata["duration"] is not None and metadata["duration"] > 0:
|
||||||
self.progress.setRange(0, duration)
|
self.progress.setRange(0, metadata["duration"])
|
||||||
self.time_total.setText(self._format_time(duration))
|
self.time_total.setText(self._format_time(metadata["duration"]))
|
||||||
if position is not None:
|
if metadata["position"] is not None:
|
||||||
self.progress.setValue(position)
|
self.progress.setValue(metadata["position"])
|
||||||
self.time_pos.setText(self._format_time(position))
|
self.time_pos.setText(self._format_time(metadata["position"]))
|
||||||
if status:
|
if metadata["status"]:
|
||||||
self.btn_play.setText("⏸" if status == "playing" else "▶")
|
self.btn_play.setText("⏸" if metadata["status"] == "playing" else "▶")
|
||||||
|
|
||||||
def _run_btctl(self, commands: list[str]) -> str:
|
|
||||||
script = "\n".join(commands + ["back", "quit"]) + "\n"
|
|
||||||
try:
|
|
||||||
result = subprocess.run(
|
|
||||||
["bluetoothctl"],
|
|
||||||
input=script,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
timeout=3,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
except (subprocess.SubprocessError, OSError):
|
|
||||||
return ""
|
|
||||||
return result.stdout.strip()
|
|
||||||
|
|
||||||
def _parse_hex_value(self, line: str) -> int | None:
|
|
||||||
start = line.find("0x")
|
|
||||||
if start == -1:
|
|
||||||
return None
|
|
||||||
hex_part = line[start:].split()[0]
|
|
||||||
try:
|
|
||||||
return int(hex_part, 16)
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _format_time(self, ms: int) -> str:
|
def _format_time(self, ms: int) -> str:
|
||||||
|
"""Форматировать время из миллисекунд."""
|
||||||
total_seconds = max(ms, 0) // 1000
|
total_seconds = max(ms, 0) // 1000
|
||||||
minutes = total_seconds // 60
|
minutes = total_seconds // 60
|
||||||
seconds = total_seconds % 60
|
seconds = total_seconds % 60
|
||||||
|
|||||||
@ -1,11 +1,5 @@
|
|||||||
from __future__ import annotations
|
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.QtCore import Qt, QTimer, QSettings
|
||||||
from PySide6.QtGui import QFont
|
from PySide6.QtGui import QFont
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
@ -19,11 +13,7 @@ from PySide6.QtWidgets import (
|
|||||||
QScroller,
|
QScroller,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
from services.bluetooth_service import BluetoothService, BluetoothDevice
|
||||||
@dataclass
|
|
||||||
class BluetoothDevice:
|
|
||||||
mac: str
|
|
||||||
name: str
|
|
||||||
|
|
||||||
|
|
||||||
class BluetoothScreen(QWidget):
|
class BluetoothScreen(QWidget):
|
||||||
@ -31,29 +21,12 @@ class BluetoothScreen(QWidget):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
self._on_back = on_back
|
self._on_back = on_back
|
||||||
self._settings = QSettings("car_ui", "ui")
|
self._settings = QSettings("car_ui", "ui")
|
||||||
self._log_path = os.path.expanduser("~/.cache/car_ui/bluetooth.log")
|
self._bt_service = BluetoothService(self)
|
||||||
self._last_error = ""
|
|
||||||
|
|
||||||
root = QVBoxLayout(self)
|
root = QVBoxLayout(self)
|
||||||
root.setContentsMargins(0, 0, 0, 0)
|
root.setContentsMargins(0, 0, 0, 0)
|
||||||
root.setSpacing(12)
|
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 = QLabel("Статус: —")
|
||||||
self.status.setObjectName("BluetoothStatus")
|
self.status.setObjectName("BluetoothStatus")
|
||||||
self.status.setFont(QFont("", 12))
|
self.status.setFont(QFont("", 12))
|
||||||
@ -100,7 +73,6 @@ class BluetoothScreen(QWidget):
|
|||||||
actions.addWidget(self.btn_connect, 1)
|
actions.addWidget(self.btn_connect, 1)
|
||||||
actions.addWidget(self.btn_disconnect, 1)
|
actions.addWidget(self.btn_disconnect, 1)
|
||||||
|
|
||||||
root.addLayout(hdr)
|
|
||||||
root.addWidget(self.status)
|
root.addWidget(self.status)
|
||||||
root.addWidget(self.list, 1)
|
root.addWidget(self.list, 1)
|
||||||
root.addLayout(actions)
|
root.addLayout(actions)
|
||||||
@ -109,7 +81,8 @@ class BluetoothScreen(QWidget):
|
|||||||
QTimer.singleShot(200, self._auto_connect_last)
|
QTimer.singleShot(200, self._auto_connect_last)
|
||||||
|
|
||||||
def refresh_list(self):
|
def refresh_list(self):
|
||||||
devices = self._paired_devices()
|
"""Обновить список сопряженных устройств."""
|
||||||
|
devices = self._bt_service.get_paired_devices()
|
||||||
self.list.clear()
|
self.list.clear()
|
||||||
|
|
||||||
for dev in devices:
|
for dev in devices:
|
||||||
@ -121,163 +94,67 @@ class BluetoothScreen(QWidget):
|
|||||||
self._update_status()
|
self._update_status()
|
||||||
|
|
||||||
def _on_select(self):
|
def _on_select(self):
|
||||||
|
"""Обработчик выбора устройства."""
|
||||||
has_selection = self._selected_mac() is not None
|
has_selection = self._selected_mac() is not None
|
||||||
self.btn_connect.setEnabled(has_selection)
|
self.btn_connect.setEnabled(has_selection)
|
||||||
self.btn_disconnect.setEnabled(has_selection)
|
self.btn_disconnect.setEnabled(has_selection)
|
||||||
self._update_status()
|
self._update_status()
|
||||||
|
|
||||||
def _selected_mac(self) -> str | None:
|
def _selected_mac(self) -> str | None:
|
||||||
|
"""Получить MAC-адрес выбранного устройства."""
|
||||||
items = self.list.selectedItems()
|
items = self.list.selectedItems()
|
||||||
if not items:
|
if not items:
|
||||||
return None
|
return None
|
||||||
return items[0].data(Qt.UserRole)
|
return items[0].data(Qt.UserRole)
|
||||||
|
|
||||||
def _auto_connect_last(self):
|
def _auto_connect_last(self):
|
||||||
|
"""Автоподключение к последнему устройству."""
|
||||||
last_mac = self._settings.value("bluetooth/last_mac", "")
|
last_mac = self._settings.value("bluetooth/last_mac", "")
|
||||||
if not last_mac:
|
if not last_mac:
|
||||||
return
|
return
|
||||||
self._connect_device(last_mac, silent=True)
|
self._connect_device(last_mac, silent=True)
|
||||||
|
|
||||||
def _make_visible(self):
|
def _make_visible(self):
|
||||||
self._last_error = ""
|
"""Сделать устройство видимым для сопряжения."""
|
||||||
script_out = self._run_btctl_script(
|
success = self._bt_service.make_discoverable()
|
||||||
[
|
if self._bt_service.last_error == "power_off":
|
||||||
"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)")
|
self.status.setText("Статус: питание BT выключено (проверьте rfkill)")
|
||||||
return
|
elif not success:
|
||||||
if self._last_error:
|
self.status.setText(f"Статус: ошибка ({self._bt_service.last_error})")
|
||||||
self.status.setText(f"Статус: ошибка ({self._last_error})")
|
|
||||||
else:
|
else:
|
||||||
self.status.setText("Статус: видим для сопряжения")
|
self.status.setText("Статус: видим для сопряжения")
|
||||||
|
|
||||||
def _connect_selected(self):
|
def _connect_selected(self):
|
||||||
|
"""Подключить выбранное устройство."""
|
||||||
mac = self._selected_mac()
|
mac = self._selected_mac()
|
||||||
if not mac:
|
if not mac:
|
||||||
return
|
return
|
||||||
self._connect_device(mac, silent=False)
|
self._connect_device(mac, silent=False)
|
||||||
|
|
||||||
def _connect_device(self, mac: str, silent: bool):
|
def _connect_device(self, mac: str, silent: bool):
|
||||||
self._last_error = ""
|
"""Подключиться к устройству по MAC."""
|
||||||
self._run_cmd(["bluetoothctl", "trust", mac])
|
success = self._bt_service.connect(mac)
|
||||||
self._run_cmd(["bluetoothctl", "connect", mac])
|
|
||||||
if not silent:
|
if not silent:
|
||||||
if self._last_error:
|
if not success:
|
||||||
self.status.setText(f"Статус: ошибка ({self._last_error})")
|
self.status.setText(f"Статус: ошибка ({self._bt_service.last_error})")
|
||||||
else:
|
else:
|
||||||
self.status.setText(f"Статус: подключаемся к {mac}")
|
self.status.setText(f"Статус: подключаемся к {mac}")
|
||||||
self._settings.setValue("bluetooth/last_mac", mac)
|
self._settings.setValue("bluetooth/last_mac", mac)
|
||||||
QTimer.singleShot(300, self._update_status)
|
QTimer.singleShot(300, self._update_status)
|
||||||
|
|
||||||
def _disconnect_selected(self):
|
def _disconnect_selected(self):
|
||||||
|
"""Отключить выбранное устройство."""
|
||||||
mac = self._selected_mac()
|
mac = self._selected_mac()
|
||||||
if not mac:
|
if not mac:
|
||||||
return
|
return
|
||||||
self._last_error = ""
|
success = self._bt_service.disconnect(mac)
|
||||||
self._run_cmd(["bluetoothctl", "disconnect", mac])
|
if not success:
|
||||||
if self._last_error:
|
self.status.setText(f"Статус: ошибка ({self._bt_service.last_error})")
|
||||||
self.status.setText(f"Статус: ошибка ({self._last_error})")
|
|
||||||
else:
|
else:
|
||||||
self.status.setText(f"Статус: отключено от {mac}")
|
self.status.setText(f"Статус: отключено от {mac}")
|
||||||
QTimer.singleShot(300, self._update_status)
|
QTimer.singleShot(300, self._update_status)
|
||||||
|
|
||||||
def _update_status(self):
|
def _update_status(self):
|
||||||
|
"""Обновить текстовый статус."""
|
||||||
mac = self._selected_mac()
|
mac = self._selected_mac()
|
||||||
if not mac:
|
self.status.setText(self._bt_service.get_status_text(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)
|
|
||||||
|
|||||||
3
services/__init__.py
Normal file
3
services/__init__.py
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
from .bluetooth_service import BluetoothService, BluetoothDevice
|
||||||
|
|
||||||
|
__all__ = ["BluetoothService", "BluetoothDevice"]
|
||||||
272
services/bluetooth_service.py
Normal file
272
services/bluetooth_service.py
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from PySide6.QtCore import QObject, Signal
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BluetoothDevice:
|
||||||
|
mac: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class BluetoothService(QObject):
|
||||||
|
"""Сервис для управления Bluetooth на Raspberry Pi."""
|
||||||
|
|
||||||
|
status_changed = Signal(str)
|
||||||
|
devices_changed = Signal(list)
|
||||||
|
connected_changed = Signal(str, bool) # mac, connected
|
||||||
|
|
||||||
|
def __init__(self, parent=None):
|
||||||
|
super().__init__(parent)
|
||||||
|
self._log_path = Path("~/.cache/car_ui/bluetooth.log").expanduser()
|
||||||
|
self._last_error = ""
|
||||||
|
|
||||||
|
# === Public API ===
|
||||||
|
|
||||||
|
def get_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 get_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 is_connected(self, mac: str) -> bool:
|
||||||
|
"""Проверить, подключено ли устройство."""
|
||||||
|
info = self.get_device_info(mac)
|
||||||
|
return info.get("Connected", "no") == "yes"
|
||||||
|
|
||||||
|
def connect(self, mac: str) -> bool:
|
||||||
|
"""Подключиться к устройству."""
|
||||||
|
self._last_error = ""
|
||||||
|
self._run_cmd(["bluetoothctl", "trust", mac])
|
||||||
|
result = self._run_cmd(["bluetoothctl", "connect", mac])
|
||||||
|
success = not self._last_error
|
||||||
|
self.connected_changed.emit(mac, success)
|
||||||
|
return success
|
||||||
|
|
||||||
|
def disconnect(self, mac: str) -> bool:
|
||||||
|
"""Отключить устройство."""
|
||||||
|
self._last_error = ""
|
||||||
|
result = self._run_cmd(["bluetoothctl", "disconnect", mac])
|
||||||
|
success = not self._last_error
|
||||||
|
self.connected_changed.emit(mac, False)
|
||||||
|
return success
|
||||||
|
|
||||||
|
def make_discoverable(self) -> bool:
|
||||||
|
"""Сделать устройство видимым для сопряжения."""
|
||||||
|
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._last_error = "power_off"
|
||||||
|
return False
|
||||||
|
return not bool(self._last_error)
|
||||||
|
|
||||||
|
def get_status_text(self, mac: str | None = None) -> str:
|
||||||
|
"""Получить текстовый статус Bluetooth."""
|
||||||
|
if not mac:
|
||||||
|
return "Статус: выберите устройство"
|
||||||
|
info = self.get_device_info(mac)
|
||||||
|
connected = info.get("Connected", "no") == "yes"
|
||||||
|
name = info.get("Name", mac)
|
||||||
|
state = "подключено" if connected else "не подключено"
|
||||||
|
return f"Статус: {name} — {state}"
|
||||||
|
|
||||||
|
def get_player_status(self) -> str | None:
|
||||||
|
"""Получить статус медиаплеера (playing/paused/stopped)."""
|
||||||
|
out = self._run_btctl(["menu player", "show"])
|
||||||
|
for line in out.splitlines():
|
||||||
|
if "Status:" in line:
|
||||||
|
return line.split("Status:", 1)[1].strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_player_metadata(self) -> dict:
|
||||||
|
"""Получить метаданные текущего трека."""
|
||||||
|
out = self._run_btctl(["menu player", "show"])
|
||||||
|
metadata = {
|
||||||
|
"source": None,
|
||||||
|
"title": None,
|
||||||
|
"artist": None,
|
||||||
|
"album": None,
|
||||||
|
"position": None,
|
||||||
|
"duration": None,
|
||||||
|
"status": None,
|
||||||
|
}
|
||||||
|
for line in out.splitlines():
|
||||||
|
if "Name:" in line:
|
||||||
|
metadata["source"] = line.split("Name:", 1)[1].strip()
|
||||||
|
elif "Track.Title:" in line:
|
||||||
|
metadata["title"] = line.split("Track.Title:", 1)[1].strip()
|
||||||
|
elif "Track.Artist:" in line:
|
||||||
|
metadata["artist"] = line.split("Track.Artist:", 1)[1].strip()
|
||||||
|
elif "Track.Album:" in line:
|
||||||
|
metadata["album"] = line.split("Track.Album:", 1)[1].strip()
|
||||||
|
elif "Position:" in line:
|
||||||
|
metadata["position"] = self._parse_hex_value(line)
|
||||||
|
elif "Track.Duration:" in line:
|
||||||
|
metadata["duration"] = self._parse_hex_value(line)
|
||||||
|
elif "Status:" in line:
|
||||||
|
metadata["status"] = line.split("Status:", 1)[1].strip()
|
||||||
|
return metadata
|
||||||
|
|
||||||
|
def player_play(self) -> None:
|
||||||
|
"""Воспроизведение."""
|
||||||
|
self._run_btctl(["menu player", "play"])
|
||||||
|
|
||||||
|
def player_pause(self) -> None:
|
||||||
|
"""Пауза."""
|
||||||
|
self._run_btctl(["menu player", "pause"])
|
||||||
|
|
||||||
|
def player_toggle(self) -> None:
|
||||||
|
"""Переключить воспроизведение/пауза."""
|
||||||
|
status = self.get_player_status()
|
||||||
|
if status == "playing":
|
||||||
|
self.player_pause()
|
||||||
|
else:
|
||||||
|
self.player_play()
|
||||||
|
|
||||||
|
def player_next(self) -> None:
|
||||||
|
"""Следующий трек."""
|
||||||
|
self._run_btctl(["menu player", "next"])
|
||||||
|
|
||||||
|
def player_previous(self) -> None:
|
||||||
|
"""Предыдущий трек."""
|
||||||
|
self._run_btctl(["menu player", "previous"])
|
||||||
|
|
||||||
|
# === Private methods ===
|
||||||
|
|
||||||
|
def _run_cmd(self, args: list[str]) -> str:
|
||||||
|
"""Выполнить команду bluetoothctl."""
|
||||||
|
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(self, commands: list[str]) -> str:
|
||||||
|
"""Выполнить скрипт в bluetoothctl интерактивном режиме."""
|
||||||
|
script = "\n".join(commands + ["back", "quit"]) + "\n"
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
["bluetoothctl"],
|
||||||
|
input=script,
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=3,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except (subprocess.SubprocessError, OSError):
|
||||||
|
self._last_error = "run-failed"
|
||||||
|
self._log(["bluetoothctl"], "", "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"], stdout, stderr, result.returncode)
|
||||||
|
return stdout
|
||||||
|
|
||||||
|
def _run_btctl_script(self, commands: list[str]) -> str:
|
||||||
|
"""Выполнить скрипт в bluetoothctl (без back в конце)."""
|
||||||
|
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 _parse_hex_value(self, line: str) -> int | None:
|
||||||
|
"""Распарсить hex значение из строки bluetoothctl."""
|
||||||
|
start = line.find("0x")
|
||||||
|
if start == -1:
|
||||||
|
return None
|
||||||
|
hex_part = line[start:].split()[0]
|
||||||
|
try:
|
||||||
|
return int(hex_part, 16)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _strip_ansi(self, text: str) -> str:
|
||||||
|
"""Удалить ANSI escape-последовательности."""
|
||||||
|
return re.sub(r"\x1b\[[0-9;]*m", "", text)
|
||||||
|
|
||||||
|
def _log(self, args: list[str], stdout: str, stderr: str, code: int):
|
||||||
|
"""Записать лог операции."""
|
||||||
|
try:
|
||||||
|
self._log_path.parent.mkdir(parents=True, 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
|
||||||
|
|
||||||
|
@property
|
||||||
|
def last_error(self) -> str:
|
||||||
|
"""Получить последнюю ошибку."""
|
||||||
|
return self._last_error
|
||||||
Loading…
x
Reference in New Issue
Block a user