358 lines
13 KiB
Python
358 lines
13 KiB
Python
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
|