From 29cf4fedc1992e3928fef872869fe6e81692d186 Mon Sep 17 00:00:00 2001 From: S4NKALP Date: Sun, 2 Aug 2026 13:37:09 +0545 Subject: [PATCH] refactor(bluetooth): drive rfkill state from kernel events --- src/services/bluetooth.py | 138 ++++++++++++++++++++++++-------------- 1 file changed, 87 insertions(+), 51 deletions(-) diff --git a/src/services/bluetooth.py b/src/services/bluetooth.py index ce1b21e..bbd180c 100644 --- a/src/services/bluetooth.py +++ b/src/services/bluetooth.py @@ -1,11 +1,13 @@ import enum -import subprocess +import os from collections.abc import Callable from typing import Any, Concatenate, ParamSpec from fabric.core.service import Property, Service, Signal from fabric.utils import Gio, GLib, Gtk, logger +from utils.functions import run_command + P = ParamSpec("P") BLUEZ_SERVICE = "org.bluez" @@ -17,6 +19,82 @@ DBUS_PROPS_IFACE = "org.freedesktop.DBus.Properties" RFKILL_PATH = "/dev/rfkill" +_rfkill_blocked: bool = False +_rfkill_fd: int = -1 +_rfkill_watch_id: int = 0 +_rfkill_listeners: set[Callable] = set() + + +def _read_rfkill_state() -> bool: + result = run_command(["rfkill", "list", "bluetooth"], timeout=5) + return "Soft blocked: yes" in (result.stdout or "") + + +def _open_rfkill_monitor() -> int: + """Read the current rfkill state and open /dev/rfkill for event-driven updates.""" + global _rfkill_blocked + try: + _rfkill_blocked = _read_rfkill_state() + return os.open(RFKILL_PATH, os.O_RDONLY | os.O_NONBLOCK) + except FileNotFoundError: + return -1 + except Exception as e: + logger.warning(f"[Bluetooth] Failed to open {RFKILL_PATH}: {e}") + return -1 + + +def rfkill_soft_blocked() -> bool: + """Return the cached rfkill soft-blocked state. + + The first call performs one ``rfkill`` query and registers a GLib watch on + ``/dev/rfkill``; afterwards the cache is refreshed only when the kernel + reports an rfkill event, instead of spawning a subprocess on every read. + """ + global _rfkill_fd, _rfkill_watch_id + if _rfkill_fd < 0: + fd = _open_rfkill_monitor() + if fd < 0: + return _read_rfkill_state() + _rfkill_fd = fd + if _rfkill_watch_id == 0: + _rfkill_watch_id = GLib.io_add_watch( + _rfkill_fd, GLib.IO_IN | GLib.IO_HUP, _on_rfkill_io, None + ) + return _rfkill_blocked + + +def _on_rfkill_io(fd, condition, _user_data) -> bool: + global _rfkill_fd, _rfkill_watch_id, _rfkill_blocked + if condition & GLib.IO_HUP: + try: + os.close(_rfkill_fd) + except OSError: + pass + _rfkill_fd = -1 + _rfkill_watch_id = 0 + return False + # Drain pending events so the fd stops reporting readable. + while True: + try: + if not os.read(fd, 32): + break + except (BlockingIOError, OSError): + break + blocked = _read_rfkill_state() + if blocked != _rfkill_blocked: + _rfkill_blocked = blocked + for listener in list(_rfkill_listeners): + listener() + return True + + +def add_rfkill_listener(callback: Callable) -> None: + _rfkill_listeners.add(callback) + + +def remove_rfkill_listener(callback: Callable) -> None: + _rfkill_listeners.discard(callback) + class BluetoothState(enum.Enum): UNAVAILABLE = "unavailable" @@ -48,23 +126,6 @@ def _make_proxy(bus: Gio.DBusConnection, path: str, iface: str) -> Gio.DBusProxy ) -def _rfkill_soft_blocked() -> bool: - try: - result = subprocess.run( - ["rfkill", "list", "bluetooth"], - capture_output=True, - text=True, - timeout=5, - ) - return "Soft blocked: yes" in result.stdout - except FileNotFoundError: - logger.warning("[Bluetooth] rfkill not found, assuming not soft blocked") - return False - except Exception as e: - logger.warning(f"[Bluetooth] rfkill check failed: {e}") - return False - - class BluetoothDevice(Service): @Signal def changed(self) -> None: ... @@ -301,13 +362,6 @@ class BluetoothDevice(Service): self._proxy.call("Pair", None, Gio.DBusCallFlags.NONE, 60000, None, _cb, None) - def remove(self): - """Remove/forget the device (must be called on the adapter).""" - # Removal is done via Adapter1.RemoveDevice — expose a convenience - # flag so the adapter can act on it. - logger.info(f"[Bluetooth] Remove requested for: {self.address}") - self.emit("changed") - def trust(self): """Mark the device as trusted.""" self.trusted = True @@ -344,10 +398,6 @@ class BluetoothDevice(Service): _id = self.connect("changed", _once) - def notifier(self, name: str, args=None): - self.notify(name) - self.emit("changed") - class BluetoothAdapter(Service): @Signal @@ -593,10 +643,6 @@ class BluetoothAdapter(Service): self._devices.clear() self._device_changed_ids.clear() - def notifier(self, name: str, *args): - self.notify(name) - self.emit("changed") - class BluetoothClient(Service): @Signal @@ -627,7 +673,7 @@ class BluetoothClient(Service): def state(self) -> str: if not self._adapters: return BluetoothState.UNAVAILABLE.value - if _rfkill_soft_blocked(): + if rfkill_soft_blocked(): return BluetoothState.INACTIVE.value for a in self._adapters.values(): if a.powered: @@ -700,16 +746,14 @@ class BluetoothClient(Service): ), ] - self._cached_rfkill_blocked = _rfkill_soft_blocked() - self._rfkill_timeout_id = GLib.timeout_add_seconds(10, self._check_rfkill) + rfkill_soft_blocked() + add_rfkill_listener(self._on_rfkill_changed) self._populate_from_object_manager() def close(self): - """Release the rfkill timer and unsubscribe from the DBus bus.""" - if self._rfkill_timeout_id: - GLib.source_remove(self._rfkill_timeout_id) - self._rfkill_timeout_id = 0 + """Stop listening for rfkill changes and unsubscribe from the DBus bus.""" + remove_rfkill_listener(self._on_rfkill_changed) for sub_id in self._bus_sub_ids: self._bus.signal_unsubscribe(sub_id) self._bus_sub_ids.clear() @@ -717,13 +761,9 @@ class BluetoothClient(Service): adapter.close() self._adapters.clear() - def _check_rfkill(self) -> bool: - blocked = _rfkill_soft_blocked() - if blocked != self._cached_rfkill_blocked: - self._cached_rfkill_blocked = blocked - self.notify("state") - self.emit("changed") - return True + def _on_rfkill_changed(self): + self.notify("state") + self.emit("changed") def _populate_from_object_manager(self): try: @@ -868,10 +908,6 @@ class BluetoothClient(Service): f"[Bluetooth] Cannot remove {device.address}: adapter not found" ) - def notifier(self, name: str, *args): - self.notify(name) - self.emit("changed") - def _device_path_to_adapter_path(device_path: str) -> str: parts = device_path.rsplit("/", 1)