diff --git a/src/services/brightness.py b/src/services/brightness.py index a9753f4..6fa3d11 100644 --- a/src/services/brightness.py +++ b/src/services/brightness.py @@ -1,27 +1,22 @@ -import time from pathlib import Path from fabric.core.service import Property, Service, Signal -from fabric.utils import Gio, GLib, exec_shell_command_async, logger, os, re +from fabric.utils import Gio, GLib, logger, os -from utils.functions import find_binary, run_command +LOGIND_BUS_NAME = "org.freedesktop.login1" +LOGIND_SESSION_PATH = "/org/freedesktop/login1/session/auto" +LOGIND_SESSION_IFACE = "org.freedesktop.login1.Session" class Brightness(Service): - """Service for controlling screen brightness using ddcutil or brightnessctl backends. + """Service for controlling screen brightness via logind DBus. - The service works with RAW values (0 to max_screen) for both backends: - - brightnessctl: raw values are device-specific (e.g., 0-96000) - - ddcutil: raw values are percentages (0-100) - - The 'screen' signal emits percentage values (0-100) for UI display. + Uses org.freedesktop.login1.Session.SetBrightness for kernel backlight. + 'screen' signal emits percentage (0-100). """ _instance = None - DDCUTIL_PARAMS = "--disable-dynamic-sleep --sleep-multiplier=0.05" MIN_CHANGE_THRESHOLD = 2 - CACHE_INTERVAL = 3 - POLL_INTERVAL = 500 def __new__(cls, *args, **kwargs): if cls._instance is None: @@ -30,37 +25,76 @@ class Brightness(Service): @Signal def screen(self, value: int) -> None: - """Signal emitted when screen brightness changes (value: percentage from 0 to 100).""" + """Emitted on brightness change. value: percentage 0-100.""" - def __init__(self, backend=None, **kwargs): - """Initialize service with automatic backend detection.""" + def __init__(self, **kwargs): if getattr(self, "_initialized", False): return self._initialized = True super().__init__(**kwargs) + self._pending_raw = None self._timer_id = None + self._poll_id = None self._file_monitor = None self._lock = GLib.Mutex() self._last_percent = -1 self._last_raw = -1 - self._last_update_time = 0 self._screen_device = None self._brightness_path = None self._max_brightness_path = None + self._dbus_proxy = None - self.backend = self._detect_backend(backend) - + self._screen_device = self._get_screen_device() self.max_screen = self._read_max_brightness() or 100 - if self.backend: - if self.backend == "ddcutil": - GLib.timeout_add(100, lambda: self._update_brightness_cache()) - else: - self._setup_polling() + if self._screen_device: + self._init_logind() + self._setup_monitoring() + logger.info( + f"[Brightness] logind backend ready, device={self._screen_device}, max={self.max_screen}" + ) + else: + logger.warning("[Brightness] No backlight device found") - def _setup_polling(self): - """Setup file monitor for brightness changes.""" + def _init_logind(self): + try: + bus = Gio.bus_get_sync(Gio.BusType.SYSTEM, None) + self._dbus_proxy = Gio.DBusProxy.new_sync( + bus, + Gio.DBusProxyFlags.NONE, + None, + LOGIND_BUS_NAME, + LOGIND_SESSION_PATH, + LOGIND_SESSION_IFACE, + None, + ) + logger.info("[Brightness] logind Session proxy initialized") + except Exception as e: + logger.error(f"[Brightness] Failed to init logind proxy: {e}") + + def _get_screen_device(self): + try: + devices = os.listdir("/sys/class/backlight") + if devices: + self._screen_device = devices[0] + base = Path(f"/sys/class/backlight/{self._screen_device}") + self._brightness_path = base / "brightness" + self._max_brightness_path = base / "max_brightness" + return self._screen_device + except Exception as e: + logger.warning(f"[Brightness] Failed to enumerate backlight: {e}") + return None + + def _read_max_brightness(self): + try: + if self._max_brightness_path and self._max_brightness_path.exists(): + return int(self._max_brightness_path.read_text().strip()) + except Exception as e: + logger.warning(f"[Brightness] Failed to read max_brightness: {e}") + return None + + def _setup_monitoring(self): try: if self._brightness_path and self._brightness_path.exists(): self._last_raw = int(self._brightness_path.read_text().strip()) @@ -69,209 +103,66 @@ class Brightness(Service): if self.max_screen > 0 else 0 ) - brightness_file = Gio.File.new_for_path(str(self._brightness_path)) self._file_monitor = brightness_file.monitor_file( Gio.FileMonitorFlags.NONE, None ) self._file_monitor.connect("changed", self._on_brightness_file_changed) + logger.info("[Brightness] File monitor active") except Exception as e: - logger.error(f"Error setting up brightness file monitor: {e}") + logger.error(f"[Brightness] Failed to setup monitor: {e}") - def _on_brightness_file_changed(self, monitor, file, other_file, event_type): - """Handle brightness file change event.""" + def _on_brightness_file_changed(self, _monitor, _file, _other_file, event_type): if event_type != Gio.FileMonitorEvent.CHANGED: return - self._check_brightness_file() + self._sync_from_sysfs() - def _check_brightness_file(self): - """Check brightness file for changes.""" + def _sync_from_sysfs(self): try: - if self._brightness_path and self._brightness_path.exists(): - raw = int(self._brightness_path.read_text().strip()) - - if raw != self._last_raw: - self._last_raw = raw - percent = ( - int((raw / self.max_screen) * 100) if self.max_screen > 0 else 0 - ) - if abs(percent - self._last_percent) >= self.MIN_CHANGE_THRESHOLD: - self._last_percent = percent - self.emit("screen", percent) + if not self._brightness_path or not self._brightness_path.exists(): + return + raw = int(self._brightness_path.read_text().strip()) + if raw == self._last_raw: + return + self._last_raw = raw + percent = int((raw / self.max_screen) * 100) if self.max_screen > 0 else 0 + if abs(percent - self._last_percent) >= self.MIN_CHANGE_THRESHOLD: + self._last_percent = percent + self.emit("screen", percent) except Exception as e: - logger.error(f"Error checking brightness file: {e}") - - def _detect_backend(self, backend): - """Detect appropriate backend for brightness control.""" - if backend: - logger.info(f"Using forced backend: {backend}") - return backend - - if find_binary("brightnessctl"): - device = self._get_screen_device() - if device: - logger.info(f"Using brightnessctl backend with device: {device}") - return "brightnessctl" - else: - logger.debug( - "brightnessctl is available but no backlight devices found in /sys/class/backlight/" - ) - - if find_binary("ddcutil"): - bus = self._detect_ddcutil_bus() - if bus != -1: - self.ddcutil_bus = bus - logger.info(f"Using ddcutil backend with I2C bus: {bus}") - return "ddcutil" - else: - logger.debug( - "ddcutil is available but no DDC/CI capable monitors detected" - ) - - logger.warning( - "No available backend for brightness control - no backlight devices or DDC/CI monitors found" - ) - return None - - def _get_screen_device(self): - """Return first backlight device from sysfs, cached.""" - if getattr(self, "_screen_device", None): - return self._screen_device + logger.error(f"[Brightness] Error reading brightness: {e}") + def _set_brightness_logind(self, device_name: str, value: int): + if not self._dbus_proxy: + return try: - devices = os.listdir("/sys/class/backlight") - if devices: - self._screen_device = devices[0] - base_path = Path(f"/sys/class/backlight/{self._screen_device}") - self._brightness_path = base_path / "brightness" - self._max_brightness_path = base_path / "max_brightness" - return self._screen_device - except Exception as e: - logger.warning( - f"[brightness] devices = os.listdir('/sys/class/backlight') failed: {e}" + self._dbus_proxy.call( + "SetBrightness", + GLib.Variant("(ssu)", ("backlight", device_name, value)), + Gio.DBusCallFlags.NONE, + -1, + None, + None, ) - return "" - - def _detect_ddcutil_bus(self): - """Detect I2C bus number for ddcutil.""" - try: - process = run_command(["ddcutil", "detect"], timeout=2) - if process.returncode == 0: - match = re.search(r"I2C bus:\s*/dev/i2c-(\d+)", process.stdout) - return int(match.group(1)) if match else -1 - return -1 except Exception as e: - logger.warning( - f"[brightness] process = run_command(['ddcutil', 'detect'], timeout=2) failed: {e}" - ) - return -1 - - def _read_max_brightness(self): - """Read maximum brightness value""" - if self.backend: - if self.backend == "ddcutil": - try: - process = run_command( - [ - "ddcutil", - "--bus", - str(self.ddcutil_bus), - *self.DDCUTIL_PARAMS.split(), - "getvcp", - "10", - ], - timeout=2, - ) - - if process.returncode == 0: - match = re.search( - r"current value\s*=\s*(\d+)\s*,\s*max value\s*=\s*(\d+)", - process.stdout, - ) - if match: - return int(match.group(2)) - except Exception as e: - logger.error(f"Error executing ddcutil: {e}") - else: - try: - if self._max_brightness_path and self._max_brightness_path.exists(): - return int(self._max_brightness_path.read_text().strip()) - except Exception as e: - logger.warning( - f"[brightness] if self._max_brightness_path and self._max_brightness_pat... failed: {e}" - ) - return None - return None - - def _update_brightness_cache(self): - """Update brightness cache with current value.""" - if self.backend == "ddcutil": - self.screen_brightness - return False + logger.error(f"[Brightness] logind SetBrightness failed: {e}") @Property(int, "read-write") def screen_brightness(self): - """Getter returns current brightness in RAW value (0 to max_screen).""" - if not self.backend: - return -1 - - if self.backend == "brightnessctl": - if self._last_raw != -1: - return self._last_raw - - try: - if self._brightness_path and self._brightness_path.exists(): - raw = int(self._brightness_path.read_text().strip()) - self._last_raw = raw - return raw - except Exception as e: - logger.error(f"Error reading brightness file: {e}") - return -1 - elif self.backend == "ddcutil": - if ( - time.time() - self._last_update_time < self.CACHE_INTERVAL - and self._last_raw != -1 - ): - return self._last_raw - - def on_ddcutil_success(stdout): - match = re.search( - r"current value\s*=\s*(\d+)\s*,\s*max value\s*=\s*(\d+)", - stdout, - ) - if match: - current = int(match.group(1)) - if current != self._last_raw: - self._last_raw = current - percent = ( - int((current / self.max_screen) * 100) - if self.max_screen > 0 - else 0 - ) - # Emit to update UI when background fetch completes - self.emit("screen", percent) - - # Update cache timestamp immediately to prevent spamming async commands - self._last_update_time = time.time() - - try: - exec_shell_command_async( - f"ddcutil --bus {self.ddcutil_bus} {self.DDCUTIL_PARAMS} getvcp 10", - lambda exit_code, stdout, stderr: ( - on_ddcutil_success(stdout) - if exit_code == 0 - else logger.error(f"ddcutil error (code {exit_code}): {stderr}") - ), - ) - except Exception as e: - logger.error(f"Error executing ddcutil async: {e}") - - return self._last_raw if self._last_raw != -1 else 0 - return None + """Current brightness in RAW value (0 to max_screen).""" + if self._last_raw != -1: + return self._last_raw + try: + if self._brightness_path and self._brightness_path.exists(): + raw = int(self._brightness_path.read_text().strip()) + self._last_raw = raw + return raw + except Exception as e: + logger.error(f"[Brightness] Error reading brightness: {e}") + return -1 @screen_brightness.setter def screen_brightness(self, value: int): - """Setter accepts brightness value in RAW (0 to max_screen).""" self._lock.lock() try: value = max(0, min(value, self.max_screen)) @@ -302,13 +193,11 @@ class Brightness(Service): self._lock.unlock() def _apply_brightness(self): - """Apply pending brightness change with optimized debouncing.""" self._lock.lock() try: if self._pending_raw is None: self._timer_id = None return False - raw = self._pending_raw self._pending_raw = None self._timer_id = None @@ -317,33 +206,19 @@ class Brightness(Service): try: percent = int((raw / self.max_screen) * 100) if self.max_screen > 0 else 0 - - if self.backend == "brightnessctl": - self.emit("screen", percent) - exec_shell_command_async( - f"brightnessctl --device '{self._screen_device}' set {raw}" - ) - elif self.backend == "ddcutil": - self._last_update_time = time.time() - self.emit("screen", percent) - exec_shell_command_async( - f"ddcutil --bus {self.ddcutil_bus} {self.DDCUTIL_PARAMS} --terse setvcp 10 {raw}", - lambda exit_code, stdout, stderr: ( - logger.error(f"ddcutil error (code {exit_code}): {stderr}") - if exit_code != 0 - else None - ), - ) + self.emit("screen", percent) + self._set_brightness_logind(self._screen_device, raw) except Exception as e: - logger.error(f"Error setting brightness: {e}") + logger.error(f"[Brightness] Error setting brightness: {e}") return False def cleanup(self): - """Clean up resources when service is stopped.""" if self._timer_id: GLib.source_remove(self._timer_id) self._timer_id = None - + if self._poll_id: + GLib.source_remove(self._poll_id) + self._poll_id = None if self._file_monitor: self._file_monitor.cancel() self._file_monitor = None diff --git a/src/window/controlcenter/main.py b/src/window/controlcenter/main.py index 0f9234d..1bae039 100644 --- a/src/window/controlcenter/main.py +++ b/src/window/controlcenter/main.py @@ -854,18 +854,11 @@ class ModusControlCenter(AppletWindow): get_brightness_service().screen_brightness = brightness_value self._updating_brightness = False - def brightness_changed(self, _, brightness_value): + def brightness_changed(self, _, brightness_percentage): if not self._signals_connected or self._updating_brightness: return - if get_brightness_service().max_screen > 0: - brightness_percentage = int( - (brightness_value / get_brightness_service().max_screen) * 100 - ) - - GLib.idle_add( - lambda: self.brightness_scale.set_value(brightness_percentage) - ) + GLib.idle_add(lambda: self.brightness_scale.set_value(brightness_percentage)) def on_volume_scroll(self, widget, event): if not self._signals_connected: diff --git a/src/window/osd/components/brightness.py b/src/window/osd/components/brightness.py index 204aa15..693a107 100644 --- a/src/window/osd/components/brightness.py +++ b/src/window/osd/components/brightness.py @@ -14,6 +14,7 @@ class BrightnessOSDContainer(BaseOSDContainer): def __init__(self, window, **kwargs): super().__init__(window, **kwargs) self.brightness_service = Brightness() + self._last_percent = None self._setup_specific_components() self._connect_specific_signals() @@ -39,35 +40,34 @@ class BrightnessOSDContainer(BaseOSDContainer): self.add(self.scale) def _connect_specific_signals(self): - self.brightness_service.connect("screen", self._on_screen_brightness_changed) + self.brightness_service.connect("screen", self._on_brightness_changed) - def _on_screen_brightness_changed(self, _sender, value, *_args): - self.update() + def _on_brightness_changed(self, _sender, percent, *_args): + if self._last_percent != percent: + self._last_percent = percent + self.update() - def _get_normalized_brightness(self): - return ( - self.brightness_service.screen_brightness - / self.brightness_service.max_screen - ) * 100 + def _sync_with_service(self): + raw = self.brightness_service.screen_brightness + max_br = self.brightness_service.max_screen + self._last_percent = int((raw / max_br) * 100) if max_br > 0 and raw >= 0 else 0 + self.scale.set_value(self._last_percent) + self._update_display() def _update_display(self): - normalized = self._get_normalized_brightness() - level = 0 if normalized == 0 else min(math.ceil(normalized / 33), 3) + percent = self._last_percent if self._last_percent is not None else 0 + level = 0 if percent == 0 else min(math.ceil(percent / 33), 3) self.osd_window_image.set_from_file(f"brightness/brightness-{level}.svg") - - self.scale.animate_value(normalized) + self.scale.animate_value(percent) def update(self, *_): self._update_display() super().update() def destroy(self): - """Disconnect signals from Brightness service""" try: - self.brightness_service.disconnect_by_func( - self._on_screen_brightness_changed - ) + self.brightness_service.disconnect_by_func(self._on_brightness_changed) except Exception as e: logger.error(f"An error occurred: {e}") super().destroy()