update
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user