refactor(brightness): replace ddcutil with logind DBus proxy
- Use org.freedesktop.login1.Session.SetBrightness for kernel backlight - Remove ddcutil dependency entirely - Fix OSD: use signal percentage value directly (like audio OSD) - Fix controlcenter: stop dividing percentage by max_screen again
This commit is contained in:
3 files changed
+105
-237
No files matched your search
+88
-213
@@ -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())
|
||||
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:
|
||||
self._setup_polling()
|
||||
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():
|
||||
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:
|
||||
if raw == self._last_raw:
|
||||
return
|
||||
self._last_raw = raw
|
||||
percent = (
|
||||
int((raw / self.max_screen) * 100) if self.max_screen > 0 else 0
|
||||
)
|
||||
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
|
||||
self._dbus_proxy.call(
|
||||
"SetBrightness",
|
||||
GLib.Variant("(ssu)", ("backlight", device_name, value)),
|
||||
Gio.DBusCallFlags.NONE,
|
||||
-1,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[brightness] devices = os.listdir('/sys/class/backlight') failed: {e}"
|
||||
)
|
||||
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":
|
||||
"""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"Error reading brightness file: {e}")
|
||||
logger.error(f"[Brightness] Error reading brightness: {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
|
||||
|
||||
@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._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
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
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()
|
||||
Reference in new issue
Block a user