Files
2026-07-12 18:56:10 +05:45

520 lines
18 KiB
Python

from __future__ import annotations
import shlex
from collections.abc import Iterator
from typing import Any
from fabric.bluetooth import BluetoothClient
from fabric.utils import GLib, idle_add, remove_handler
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from modules.launcher.base import LauncherPlugin
from utils.functions import run_command
class BluetoothPlugin(LauncherPlugin):
@property
def name(self) -> str:
return "bluetooth"
@property
def icon(self) -> str:
return "bluetooth"
@property
def keywords(self) -> list[str]:
return ["bt"]
def __init__(self, handler):
super().__init__(handler)
self._query_handler: int = 0
self._signal_refresh_handler: int = 0
self._is_active: bool = False
self._client = BluetoothClient()
if self._client is not None:
self._client.connect("device-added", lambda *_: self._on_bt_changed())
self._client.connect("notify::enabled", lambda *_: self._on_bt_changed())
self._client.connect("notify::scanning", lambda *_: self._on_bt_changed())
self._signal_matches = []
self.current_slots: list = []
def on_search(self, text: str) -> None:
self.query_bluetooth(text)
def stop(self):
if self._query_handler:
remove_handler(self._query_handler)
self._query_handler = 0
if self._signal_refresh_handler:
GLib.source_remove(self._signal_refresh_handler)
self._signal_refresh_handler = 0
self._signal_matches.clear()
self.current_slots.clear()
def query_bluetooth(self, text: str) -> None:
self._is_active = True
self.handler.start("bluetooth")
self.current_slots.clear()
text_stripped = text.strip()
commands = {
"power": self._handle_power_command,
"scan": self._handle_scan_command,
"connect": self._handle_connect_command,
"disconnect": self._handle_disconnect_command,
"help": lambda t: self._show_help(),
}
for prefix, handler in commands.items():
if text_stripped.lower().startswith(prefix):
handler(text_stripped)
return
if (
self._client is not None
and hasattr(self._client, "scanning")
and self._client.scanning
): # type: ignore
self._show_scanning_indicator()
items = self._enumerate(text)
if not items:
self._show_empty()
return
self._query_handler = idle_add(self._bake_next_slot, iter(items), pin=True)
def _on_bt_changed(self, *args, **kwargs):
if not self._is_active:
return
if self._signal_refresh_handler:
return
self._signal_refresh_handler = GLib.timeout_add(250, self._on_refresh_timeout)
def _on_refresh_timeout(self) -> bool:
self._signal_refresh_handler = 0
if self._is_active:
self.handler.start("bluetooth")
if hasattr(self.handler, "clear_viewport"):
self.handler.clear_viewport()
self.query_bluetooth("")
return False
def _adapters(self) -> list[Any]:
if self._client is not None and hasattr(self._client, "adapters"):
adapters = self._client.adapters # type: ignore
if adapters:
return list(adapters) # type: ignore
return []
def _devices(self) -> list[tuple[Any, Any | None]]:
if self._client is not None and hasattr(self._client, "devices"):
devices = self._client.devices # type: ignore
if devices is not None:
result: list[tuple[Any, Any | None]] = []
for d in devices:
adapter = d.adapter if hasattr(d, "adapter") else None
result.append((d, adapter))
return result
return []
def _enumerate(self, text: str) -> list[dict]:
text = (text or "").strip().lower()
items: list[dict] = []
# First add power/scan controls per adapter
for ad in self._adapters():
if hasattr(ad, "Alias"):
name = ad.Alias # type: ignore
elif hasattr(ad, "Name"):
name = ad.Name # type: ignore
else:
name = "Adapter"
if hasattr(ad, "Powered"):
powered = bool(ad.Powered) # type: ignore
elif hasattr(ad, "powered"):
powered = bool(ad.powered) # type: ignore
else:
powered = False
if hasattr(ad, "Discovering"):
discovering = bool(ad.Discovering) # type: ignore
elif hasattr(ad, "scanning"):
discovering = bool(ad.scanning) # type: ignore
else:
discovering = False
items.append(
{
"kind": "adapter",
"adapter": ad,
"name": f"{name}",
"subtitle": ("On" if powered else "Off")
+ (" • Scanning" if discovering else ""),
}
)
# Then add devices
for dev, adapter in self._devices():
if hasattr(dev, "Alias"):
alias = dev.Alias # type: ignore
elif hasattr(dev, "Name"):
alias = dev.Name # type: ignore
elif hasattr(dev, "name"):
alias = dev.name # type: ignore
else:
alias = "Unknown"
if text and (alias.lower().find(text) < 0):
continue
if hasattr(dev, "Connected"):
connected = bool(dev.Connected) # type: ignore
elif hasattr(dev, "connected"):
connected = bool(dev.connected) # type: ignore
else:
connected = False
if hasattr(dev, "Paired"):
paired = bool(dev.Paired) # type: ignore
elif hasattr(dev, "paired"):
paired = bool(dev.paired) # type: ignore
else:
paired = False
if hasattr(dev, "Trusted"):
trusted = bool(dev.Trusted) # type: ignore
elif hasattr(dev, "trusted"):
trusted = bool(dev.trusted) # type: ignore
else:
trusted = False
icon = "bluetooth"
subtitle_bits = []
if adapter:
if hasattr(adapter, "Alias") and adapter.Alias: # type: ignore
subtitle_bits.append(adapter.Alias) # type: ignore
elif hasattr(adapter, "Name") and adapter.Name: # type: ignore
subtitle_bits.append(adapter.Name) # type: ignore
if paired:
subtitle_bits.append("Paired")
if trusted:
subtitle_bits.append("Trusted")
if connected:
subtitle_bits.append("Connected")
items.append(
{
"kind": "device",
"device": dev,
"name": alias,
"subtitle": " • ".join(subtitle_bits) or "",
"icon": icon,
"connected": connected,
}
)
return items
def _show_empty(self) -> None:
self.handler.slot_ready(
Button(
style_classes="app-slot bluetooth-empty",
child=Box(
orientation="h",
spacing=12,
children=[
Image(icon_name="bluetooth-disabled-symbolic", size=32),
Label(
label="No Bluetooth items",
style_classes="bluetooth-empty-text",
h_align="center",
),
],
),
tooltip_text="No Bluetooth adapters/devices",
),
"bluetooth",
)
self.handler.done()
def _show_no_results(self) -> None:
self.handler.slot_ready(
Button(
style_classes="app-slot bluetooth-no-results",
child=Box(
orientation="h",
spacing=12,
children=[
Label(
label="No devices match your search",
style_classes="bluetooth-no-results-text",
h_align="center",
),
],
),
),
"bluetooth",
)
self.handler.done()
def _show_scanning_indicator(self) -> None:
self.handler.slot_ready(
Button(
style_classes="app-slot bluetooth-scanning",
child=Box(
orientation="h",
spacing=12,
children=[
Image(icon_name="content-loading-symbolic", size=24),
Label(
label="Scanning for Bluetooth devices...",
style_classes="bluetooth-scanning-text",
h_align="center",
),
],
),
tooltip_text="Scanning is in progress",
),
"bluetooth",
)
def _show_error_message(self, message: str) -> None:
self.handler.slot_ready(
Button(
style_classes="app-slot bluetooth-error",
child=Box(
orientation="h",
spacing=12,
children=[
Label(
label=message,
style_classes="bluetooth-error-text",
h_align="center",
),
],
),
on_clicked=lambda *_: self.handler.launched(),
),
"bluetooth",
)
self.handler.done()
def _show_help(self) -> None:
help_text = (
"Available commands:\n"
"• power on|off (Toggle if no arg)\n"
"• scan start|stop (Toggle if no arg)\n"
"• connect <device_name>\n"
"• disconnect <device_name>\n"
)
self.handler.slot_ready(
Button(
style_classes="app-slot bluetooth-help",
child=Box(
orientation="h",
spacing=12,
children=[
Label(
label=help_text,
style_classes="bluetooth-help-text",
h_align="center",
),
],
),
on_clicked=lambda *_: self.handler.launched(),
),
"bluetooth",
)
self.handler.done()
def _bake_next_slot(self, iterator: Iterator[dict]) -> bool:
try:
item = next(iterator)
except StopIteration:
idle_add(self.handler.done)
return False
self._create_slot(item)
return True
def _create_slot(self, item: dict) -> None:
icon_name = item.get("icon") or (
"bluetooth-active" if item.get("kind") == "device" else "bluetooth"
)
slot_content = Box(
orientation="h",
spacing=12,
children=[
Image(icon_name=icon_name, h_align="start", size=32),
Box(
orientation="v",
children=[
Label(
label=item.get("name", "Unknown"),
style_classes="bluetooth-name",
h_align="start",
v_align="start",
),
Label(
label=item.get("subtitle", ""),
style_classes="bluetooth-subtitle",
h_align="start",
v_align="start",
),
],
),
],
)
tooltip = (
"Enter: Toggle Connect/Disconnect\nShift+Enter: Pair/Remove"
if item.get("kind") == "device"
else "Enter: Toggle Power\nShift+Enter: Scan"
)
self.handler.slot_ready(
Button(
style_classes="app-slot bluetooth-slot",
child=slot_content,
tooltip_text=tooltip,
on_clicked=lambda *_: self._on_slot_click(item),
),
"bluetooth",
)
self.current_slots.append({"widget": None, "item": item})
def _on_slot_click(self, item: dict) -> None:
kind = item.get("kind")
if kind == "device":
dev = item.get("device")
if self._client is not None:
if not item.get("connected") and hasattr(dev, "set_connecting"):
dev.set_connecting(True) # type: ignore
if hasattr(dev, "connected"):
dev.connected = not bool(item.get("connected")) # type: ignore
self.handler.launched()
return
if kind == "adapter":
ad = item.get("adapter")
if self._client is not None and hasattr(ad, "toggle_power"):
ad.toggle_power() # type: ignore
else:
current = bool(ad.powered) if hasattr(ad, "powered") else False
if hasattr(ad, "powered"):
ad.powered = not current # type: ignore
self.handler.launched()
def handle_external(self, command: str, args: str) -> None:
# Map external command to internal logic
# This is a simplified version of handle_direct
# In a real scenario, we might want to reuse handle_direct logic
pass
def _btctl(self, subcommand: str) -> bool:
try:
cmd = shlex.split(f"bluetoothctl {subcommand}")
result = run_command(cmd, timeout=5)
return result.returncode == 0
except Exception:
return False
def _handle_power_command(self, text: str) -> None:
parts = text.strip()
if not parts.endswith(";"):
self._show_error_message(
"Bluetooth: command must end with ';'\nUsage: power on; | power off;"
)
return
parts = parts[:-1].strip()
tokens = parts.split()
state = tokens[1].lower() if len(tokens) > 1 else ""
if self._client is not None and hasattr(self._client, "toggle_power"):
if state:
desired = state in ("on", "1", "true", "enable")
self._client.enabled = desired # type: ignore
else:
self._client.toggle_power() # type: ignore
else:
if state in ("on", "1", "true", "enable"):
self._btctl("power on")
elif state in ("off", "0", "false", "disable"):
self._btctl("power off")
else:
self._btctl("power off")
self._btctl("power on")
def _handle_scan_command(self, text: str) -> None:
parts = text.strip()
if not parts.endswith(";"):
self._show_error_message(
"Bluetooth: command must end with ';'\nUsage: scan start; | scan stop;"
)
return
parts = parts[:-1].strip()
tokens = parts.split()
action = tokens[1].lower() if len(tokens) > 1 else ""
if self._client is not None:
if action in ("start", "on", "1") and hasattr(self._client, "scan"):
self._client.scan() # type: ignore
elif action in ("stop", "off", "0"):
self._client.scanning = False # type: ignore
elif hasattr(self._client, "toggle_scan"):
self._client.toggle_scan() # type: ignore
def _handle_connect_command(self, text: str) -> None:
parts = text.strip()
if not parts.endswith(";"):
self._show_error_message(
"Bluetooth: command must end with ';'\nUsage: connect AA:BB:CC:DD:EE:FF;"
)
return
parts = parts[:-1].strip()
tokens = parts.split()
if len(tokens) < 2:
self._show_help()
return
name = " ".join(tokens[1:])
if self._client is not None:
self._with_device_by_name(
name,
lambda d: setattr(d, "connected", True)
if hasattr(d, "connected")
else None,
)
def _handle_disconnect_command(self, text: str) -> None:
parts = text.strip()
if not parts.endswith(";"):
self._show_error_message(
"Bluetooth: command must end with ';'\nUsage: disconnect AA:BB:CC:DD:EE:FF;"
)
return
parts = parts[:-1].strip()
tokens = parts.split()
if len(tokens) < 2:
self._show_help()
return
name = " ".join(tokens[1:])
if self._client is not None:
self._with_device_by_name(
name,
lambda d: setattr(d, "connected", False)
if hasattr(d, "connected")
else None,
)
def _with_device_by_name(self, name: str, op) -> bool:
name_l = name.strip().lower()
if self._client is None:
return False
devices = self._client.devices if hasattr(self._client, "devices") else [] # type: ignore
for d in devices:
alias = (
d.Alias
if hasattr(d, "Alias")
else (
d.Name
if hasattr(d, "Name")
else (d.name if hasattr(d, "name") else "")
)
) # type: ignore
if str(alias).strip().lower() == name_l:
op(d)
return True
return False