This commit is contained in:
2026-06-03 21:26:54 +02:00
parent 05e6b8d061
commit d21e5175d5
125 changed files with 41986 additions and 0 deletions
+357
View File
@@ -0,0 +1,357 @@
import os
import subprocess
import re
import time
from fabric.core.service import Property, Service, Signal
from fabric.utils import exec_shell_command_async, monitor_file
from gi.repository import GLib
from loguru import logger
import utils.functions as helpers
from utils.colors import Colors
class Brightness(Service):
"""Service for controlling screen brightness using ddcutil or brightnessctl backends.
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.
"""
instance = None
DDCUTIL_PARAMS = "--disable-dynamic-sleep --sleep-multiplier=0.05"
MIN_CHANGE_THRESHOLD = 2 # Minimum brightness change to apply (percent)
CACHE_INTERVAL = 3 # Cache duration in seconds
POLL_INTERVAL = 500 # File polling interval in ms
@staticmethod
def get_initial():
"""Singleton to get Brightness service instance."""
if Brightness.instance is None:
Brightness.instance = Brightness()
return Brightness.instance
@Signal
def screen(self, value: int) -> None:
"""Signal emitted when screen brightness changes (value: percentage from 0 to 100)."""
pass
def __init__(self, backend=None, **kwargs):
"""Initialize service with automatic backend detection."""
super().__init__(**kwargs)
self._pending_raw = None
self._timer_id = None
self._poll_timer_id = None
self._lock = GLib.Mutex()
self._last_percent = -1
self._last_raw = -1
self._last_update_time = 0
self._last_file_mtime = 0
# Detect backend
self.backend = self._detect_backend(backend)
self.max_screen = self._read_max_brightness() or 100
if self.backend:
if self.backend == "ddcutil":
# Initialize brightness cache
GLib.timeout_add(100, lambda: self._update_brightness_cache())
else:
# Setup polling for brightness file
self._setup_polling()
def _setup_polling(self):
"""Setup periodic polling of brightness file."""
try:
file_path = f"/sys/class/backlight/{self._get_screen_device()}/brightness"
if os.path.exists(file_path):
# Initialize cache with current value
with open(file_path) as f:
self._last_raw = int(f.readline().strip())
self._last_percent = (
int((self._last_raw / self.max_screen) * 100)
if self.max_screen > 0
else 0
)
self._last_file_mtime = os.path.getmtime(file_path)
self._poll_timer_id = GLib.timeout_add(
self.POLL_INTERVAL, self._check_brightness_file
)
except Exception as e:
logger.error(f"Error setting up brightness polling: {e}")
def _check_brightness_file(self):
"""Periodically check brightness file for changes."""
try:
file_path = f"/sys/class/backlight/{self._get_screen_device()}/brightness"
if os.path.exists(file_path):
current_mtime = os.path.getmtime(file_path)
if current_mtime > self._last_file_mtime:
self._last_file_mtime = current_mtime
with open(file_path) as f:
raw = int(f.readline().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)
return True
except Exception as e:
logger.error(f"Error checking brightness file: {e}")
return True
def _detect_backend(self, backend):
"""Detect appropriate backend for brightness control."""
if backend:
logger.info(f"Using forced backend: {backend}")
return backend
# Try brightnessctl first (preferred for laptop internal displays)
if helpers.executable_exists("brightnessctl"):
device = self._get_screen_device()
if device: # Non-empty string means device found
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/"
)
# Try ddcutil for external monitors (via DDC/CI protocol)
if helpers.executable_exists("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."""
try:
return os.listdir("/sys/class/backlight")[0]
except Exception:
return ""
def _detect_ddcutil_bus(self):
"""Detect I2C bus number for ddcutil."""
try:
process = subprocess.run(
["ddcutil", "detect"], text=True, capture_output=True, 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:
return -1
def _read_max_brightness(self):
"""Read maximum brightness value"""
if self.backend:
if self.backend == "ddcutil":
try:
process = subprocess.run(
[
"ddcutil",
"--bus",
str(self.ddcutil_bus),
*self.DDCUTIL_PARAMS.split(),
"getvcp",
"10",
],
text=True,
capture_output=True,
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:
with open(
f"/sys/class/backlight/{self._get_screen_device()}/max_brightness"
) as f:
return int(f.readline().strip())
except Exception:
return None
def _update_brightness_cache(self):
"""Update brightness cache with current value."""
if self.backend == "ddcutil":
self.screen_brightness # This will update the cache
return False
@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":
# Return cached raw value if available
if self._last_raw != -1:
return self._last_raw
try:
with open(
f"/sys/class/backlight/{self._get_screen_device()}/brightness"
) as f:
raw = int(f.readline().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":
# Use cached value if recent enough
if (
time.time() - self._last_update_time < self.CACHE_INTERVAL
and self._last_raw != -1
):
return self._last_raw
try:
process = subprocess.run(
[
"ddcutil",
"--bus",
str(self.ddcutil_bus),
*self.DDCUTIL_PARAMS.split(),
"getvcp",
"10",
],
text=True,
capture_output=True,
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:
current = int(match.group(1))
# For ddcutil, raw value IS the current value (0-100)
self._last_raw = current
self._last_update_time = time.time()
return current
except Exception as e:
logger.error(f"Error executing ddcutil: {e}")
return self._last_raw if self._last_raw != -1 else -1
@screen_brightness.setter
def screen_brightness(self, value: int):
"""Setter accepts brightness value in RAW (0 to max_screen)."""
self._lock.lock()
try:
# Limit value between 0 and max_screen
value = max(0, min(value, self.max_screen))
# Check if change is significant enough (in percentage terms)
current_percent = (
int((self._last_raw / self.max_screen) * 100)
if self._last_raw != -1 and self.max_screen > 0
else -1
)
new_percent = (
int((value / self.max_screen) * 100) if self.max_screen > 0 else 0
)
if (
abs(new_percent - current_percent) < self.MIN_CHANGE_THRESHOLD
and self._last_raw != -1
):
return
self._pending_raw = value
# Use a single timer for applying changes
if self._timer_id:
GLib.source_remove(self._timer_id)
self._timer_id = GLib.timeout_add(50, self._apply_brightness)
finally:
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
finally:
self._lock.unlock()
try:
# Update cache before executing command for faster UI response
self._last_raw = raw
# Calculate percentage for signal emission
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._get_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,
)
except Exception as e:
logger.error(f"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_timer_id:
GLib.source_remove(self._poll_timer_id)
self._poll_timer_id = None