Dotfiles/desktopenvs/hyprlua/astal-menu/modules/bluetooth.py

204 lines
7.1 KiB
Python

"""Bluetooth quad: powered by AstalBluetooth (bluez wrapper).
Discovery, connect, disconnect and a local connection history (bluez keeps none, so
we record successful connects in ~/.cache/astal-menu/bt-history.json). 'discovery'
and 'history' are per-quad feature toggles.
"""
from __future__ import annotations
import json
import time
import gi
gi.require_version("Gtk", "4.0")
from gi.repository import Gtk # noqa: E402
from module_base import Feature, ModuleContext, ModuleInstance, ModuleSpec
from paths import CACHE_DIR
_HISTORY = CACHE_DIR / "bt-history.json"
def _load_history() -> list[dict]:
try:
return json.loads(_HISTORY.read_text())
except (FileNotFoundError, json.JSONDecodeError):
return []
def _record_history(address: str, name: str) -> None:
hist = [h for h in _load_history() if h.get("address") != address]
hist.insert(0, {"address": address, "name": name, "ts": int(time.time())})
CACHE_DIR.mkdir(parents=True, exist_ok=True)
_HISTORY.write_text(json.dumps(hist[:50], indent=2))
class _BluetoothView(Gtk.Box):
def __init__(self, ctx: ModuleContext, full: bool):
super().__init__(orientation=Gtk.Orientation.VERTICAL, spacing=8)
self.add_css_class("bt-view")
self.ctx = ctx
self.full = full
self.bt = ctx.services.bluetooth
self._recorded: set[str] = set()
self.append(self._build_header())
self._list = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
self._list.add_css_class("bt-list")
if full:
scroller = Gtk.ScrolledWindow(vexpand=True,
hscrollbar_policy=Gtk.PolicyType.NEVER)
scroller.set_child(self._list)
self.append(scroller)
else:
self.append(self._list)
if self.bt is not None:
self.bt.connect("notify::devices", lambda *_: self._refresh())
self.bt.connect("notify::is-powered", lambda *_: self._refresh())
self._refresh()
def _adapter(self):
return self.bt.get_adapter() if self.bt else None
def _build_header(self) -> Gtk.Widget:
header = Gtk.CenterBox()
header.add_css_class("bt-header")
left = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
left.append(Gtk.Label(label="Power"))
self._power = Gtk.Switch(active=bool(self.bt and self.bt.get_is_powered()))
self._power.connect("state-set", self._on_power)
left.append(self._power)
header.set_start_widget(left)
if self.full and self.ctx.feature("discovery", True):
self._scan = Gtk.ToggleButton(label=" Scan")
self._scan.add_css_class("quad-action")
self._scan.connect("toggled", self._on_scan)
header.set_end_widget(self._scan)
return header
def _on_power(self, _sw, value: bool) -> bool:
ad = self._adapter()
if ad:
ad.set_powered(value)
return False
def _on_scan(self, btn: Gtk.ToggleButton) -> None:
ad = self._adapter()
if not ad:
return
if btn.get_active():
ad.start_discovery()
else:
ad.stop_discovery()
# -- device list -------------------------------------------------------
def _refresh(self) -> None:
child = self._list.get_first_child()
while child:
self._list.remove(child)
child = self._list.get_first_child()
if not self.bt:
self._list.append(Gtk.Label(label="No Bluetooth adapter"))
return
devices = list(self.bt.get_devices())
devices.sort(key=lambda d: (not d.get_connected(), not d.get_paired(),
(d.get_name() or d.get_address() or "").lower()))
if not self.full:
devices = [d for d in devices if d.get_connected() or d.get_paired()][:4]
for dev in devices:
self._list.append(self._device_row(dev))
if self.full and self.ctx.feature("history", True):
self._list.append(self._history_section())
def _device_row(self, dev) -> Gtk.Widget:
row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10)
row.add_css_class("bt-row")
icon = Gtk.Image.new_from_icon_name((dev.get_icon() or "bluetooth") + "-symbolic")
row.append(icon)
name = dev.get_name() or dev.get_address() or "Unknown"
status = "connected" if dev.get_connected() else (
"connecting…" if dev.get_connecting() else
("paired" if dev.get_paired() else ""))
lbl = Gtk.Label(label=name, xalign=0.0, hexpand=True)
if status:
lbl.set_tooltip_text(status)
row.append(lbl)
if status:
tag = Gtk.Label(label=status)
tag.add_css_class("bt-status")
row.append(tag)
btn = Gtk.Button()
btn.add_css_class("quad-action")
if dev.get_connected():
btn.set_label("Disconnect")
btn.connect("clicked", lambda *_: dev.disconnect_device(None, self._noop))
else:
btn.set_label("Connect")
btn.connect("clicked", lambda *_, d=dev: self._connect(d))
row.append(btn)
if dev.get_connected() and self.ctx.feature("history", True):
self._maybe_record(dev)
return row
def _connect(self, dev) -> None:
def done(d, res):
try:
d.connect_device_finish(res)
if self.ctx.feature("history", True):
self._maybe_record(d, force=True)
except Exception:
pass
dev.connect_device(None, done)
def _maybe_record(self, dev, force: bool = False) -> None:
addr = dev.get_address() or ""
if not addr or (addr in self._recorded and not force):
return
self._recorded.add(addr)
_record_history(addr, dev.get_name() or addr)
@staticmethod
def _noop(obj, res) -> None:
try:
obj.disconnect_device_finish(res)
except Exception:
pass
def _history_section(self) -> Gtk.Widget:
box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4)
box.add_css_class("bt-history")
box.append(Gtk.Separator())
box.append(Gtk.Label(label="History", xalign=0.0))
for h in _load_history()[:8]:
when = time.strftime("%d.%m %H:%M", time.localtime(h.get("ts", 0)))
box.append(Gtk.Label(label=f"{h.get('name', '?')} · {when}", xalign=0.0))
return box
def build(ctx: ModuleContext) -> ModuleInstance:
# Feature toggles (discovery/history) rebuild the whole card via QuadCard, so
# both views are recreated with the current features — no partial refresh here.
compact = _BluetoothView(ctx, full=False)
expanded = _BluetoothView(ctx, full=True)
return ModuleInstance(compact=compact, expanded=expanded)
SPEC = ModuleSpec(
id="bluetooth",
title="Bluetooth",
icon="", # nf-fa-bluetooth
build=build,
default_enabled=True,
features=[Feature("discovery", "Device discovery", True),
Feature("history", "Connection history", True)],
)