From feac2a7aa824e4e3d9621f80084d6393b4cb5e06 Mon Sep 17 00:00:00 2001 From: Krzysztof Rudnicki Date: Fri, 20 Feb 2026 20:43:37 +0100 Subject: [PATCH] fix: install sh i3 --- .pre-commit-config.yaml | 2 +- .../i3-configuration/install.sh | 49 +- python_pkg/brightness_controller/__init__.py | 1 + .../auto_brightness_daemon.py | 199 ++++++++ .../brightness_controller.py | 447 ++++++++++++++++++ 5 files changed, 675 insertions(+), 23 deletions(-) create mode 100644 python_pkg/brightness_controller/__init__.py create mode 100755 python_pkg/brightness_controller/auto_brightness_daemon.py create mode 100755 python_pkg/brightness_controller/brightness_controller.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0471846..e9557dd 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -162,7 +162,7 @@ repos: - id: codespell args: - --skip=*.json,*.lock,*.min.js,*.min.css,.git,__pycache__,.venv,*.txt - - --ignore-words-list=ans,ect,nd,som,sur,te,nam,numer,lew,sie,wil,postion,clen,ther,folow,derrive,ony,tje,noe,theses,crate,doubleclick,wile + - --ignore-words-list=als,ans,ect,nd,som,sur,te,nam,numer,lew,sie,wil,postion,clen,ther,folow,derrive,ony,tje,noe,theses,crate,doubleclick,wile exclude: ^(Bash/ffmpeg-build/|LaTeX/|CPP/|.*\.geojson$) # =========================================================================== diff --git a/linux_configuration/i3-configuration/install.sh b/linux_configuration/i3-configuration/install.sh index 939175b..3a7ed48 100755 --- a/linux_configuration/i3-configuration/install.sh +++ b/linux_configuration/i3-configuration/install.sh @@ -2,40 +2,45 @@ # Function to detect if the system is Ubuntu is_ubuntu() { - [ -f /etc/os-release ] && grep -qi 'ubuntu' /etc/os-release + [ -f /etc/os-release ] && grep -qi 'ubuntu' /etc/os-release } # Function to detect screen resolution and set font size set_font_size() { - resolution=$(xdpyinfo | grep dimensions | awk '{print $2}') - width=$(echo "$resolution" | cut -d 'x' -f 1) - # Do not change this font size, it actually makes i3blocks unbearable to look at: - # Icons (like for slack) are too small and i3blocks are too big - # Network monitor jumping becomes annoying - if [ "$width" -gt 1920 ]; then - echo "8" - else - echo "8" - fi + width="" + if command -v xdpyinfo >/dev/null 2>&1; then + resolution=$(xdpyinfo | grep dimensions | awk '{print $2}') + width=$(echo "$resolution" | cut -d 'x' -f 1) + elif command -v xrandr >/dev/null 2>&1; then + width=$(xrandr --current 2>/dev/null | awk '/\*/ {print $1}' | head -1 | cut -d 'x' -f 1) + fi + # Do not change this font size, it actually makes i3blocks unbearable to look at: + # Icons (like for slack) are too small and i3blocks are too big + # Network monitor jumping becomes annoying + if [ -n "$width" ] && [ "$width" -gt 1920 ] 2>/dev/null; then + echo "8" + else + echo "8" + fi } # Check if Intel GPU is detected if lspci | grep -i 'vga' | grep -i 'intel'; then - if is_ubuntu; then - sudo apt-get update - sudo apt-get install -y intel-gpu-tools - sudo setcap cap_perfmon+ep /usr/bin/intel_gpu_top - else - yes | sudo pacman -S --needed intel-gpu-tools - sudo setcap cap_perfmon+ep /usr/bin/intel_gpu_top - fi + if is_ubuntu; then + sudo apt-get update + sudo apt-get install -y intel-gpu-tools + sudo setcap cap_perfmon+ep /usr/bin/intel_gpu_top + else + yes | sudo pacman -S --needed intel-gpu-tools + sudo setcap cap_perfmon+ep /usr/bin/intel_gpu_top + fi fi if is_ubuntu; then - sudo apt-get update - sudo apt-get install -y fonts-dejavu-core fonts-noto fonts-font-awesome bc jq iw pulseaudio-utils + sudo apt-get update + sudo apt-get install -y fonts-dejavu-core fonts-noto fonts-font-awesome bc jq iw pulseaudio-utils else - yes | sudo pacman -S --needed ttf-dejavu noto-fonts ttf-font-awesome bc jq iw acpi + yes | sudo pacman -S --needed ttf-dejavu noto-fonts ttf-font-awesome bc jq iw acpi fi # Set font size based on screen resolution diff --git a/python_pkg/brightness_controller/__init__.py b/python_pkg/brightness_controller/__init__.py new file mode 100644 index 0000000..6064584 --- /dev/null +++ b/python_pkg/brightness_controller/__init__.py @@ -0,0 +1 @@ +"""Brightness controller package for screen brightness management.""" diff --git a/python_pkg/brightness_controller/auto_brightness_daemon.py b/python_pkg/brightness_controller/auto_brightness_daemon.py new file mode 100755 index 0000000..fb8fd2e --- /dev/null +++ b/python_pkg/brightness_controller/auto_brightness_daemon.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +"""Background daemon for automatic brightness adjustment via ambient light sensor. + +Reads the IIO ambient light sensor periodically and adjusts screen backlight +using brightnessctl. Designed to run as a systemd user service. + +Usage: + python -m python_pkg.brightness_controller.auto_brightness_daemon + # or directly: + ./auto_brightness_daemon.py + +Control file: ~/.config/brightness-auto/enabled + - Write "1" to enable auto-brightness, "0" to disable. + - The daemon checks this file each cycle. + - The GUI writes to this file to toggle auto mode. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +import shutil +import signal +import subprocess +import sys +import time + +POLL_INTERVAL_S = 2.0 +MIN_CHANGE_PERCENT = 2 + +# Smoothing: move at most this many % per tick to avoid jarring jumps. +MAX_STEP_PER_TICK = 5 + +_BRIGHTNESSCTL = shutil.which("brightnessctl") or "/usr/bin/brightnessctl" + +# Minimum fields in brightnessctl machine-readable info output. +_MIN_BRIGHTNESSCTL_FIELDS = 4 + +CONFIG_DIR = Path.home() / ".config" / "brightness-auto" +ENABLED_FILE = CONFIG_DIR / "enabled" + +# Lux-to-brightness mapping (must match the GUI curve). +LUX_CURVE: list[tuple[float, int]] = [ + (0.0, 10), + (5.0, 40), + (50.0, 75), + (200.0, 90), + (500.0, 95), + (1000.0, 100), + (5000.0, 100), +] + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [auto-brightness] %(message)s", + datefmt="%H:%M:%S", +) +log = logging.getLogger(__name__) + + +def _find_als_device() -> Path | None: + """Find the first IIO ambient light sensor device path.""" + matches = list(Path("/sys/bus/iio/devices").glob("*/in_illuminance_raw")) + if matches: + return matches[0].parent + return None + + +def _read_lux(als_path: Path) -> float: + """Read current illuminance in lux from the ALS.""" + raw = float((als_path / "in_illuminance_raw").read_text().strip()) + try: + scale = float((als_path / "in_illuminance_scale").read_text().strip()) + except (FileNotFoundError, ValueError): + scale = 1.0 + try: + offset = float((als_path / "in_illuminance_offset").read_text().strip()) + except (FileNotFoundError, ValueError): + offset = 0.0 + return (raw + offset) * scale + + +def _lux_to_brightness(lux: float) -> int: + """Map a lux reading to a screen brightness percentage using the curve.""" + if lux <= LUX_CURVE[0][0]: + return LUX_CURVE[0][1] + if lux >= LUX_CURVE[-1][0]: + return LUX_CURVE[-1][1] + + for i in range(len(LUX_CURVE) - 1): + lux_lo, bri_lo = LUX_CURVE[i] + lux_hi, bri_hi = LUX_CURVE[i + 1] + if lux_lo <= lux <= lux_hi: + ratio = (lux - lux_lo) / (lux_hi - lux_lo) + return int(bri_lo + ratio * (bri_hi - bri_lo)) + + return LUX_CURVE[-1][1] + + +def _get_brightness() -> int: + """Get current backlight brightness percentage.""" + result = subprocess.run( + [_BRIGHTNESSCTL, "-m", "info"], + capture_output=True, + text=True, + check=False, + ) + for line in result.stdout.strip().splitlines(): + parts = line.split(",") + if len(parts) >= _MIN_BRIGHTNESSCTL_FIELDS and parts[1] == "backlight": + return int(parts[3].rstrip("%")) + return -1 + + +def _set_brightness(percent: int) -> None: + """Set backlight brightness percentage.""" + subprocess.run( + [_BRIGHTNESSCTL, "-q", "set", f"{percent}%"], + check=False, + ) + + +def _is_enabled() -> bool: + """Check if auto-brightness is enabled via the control file.""" + try: + return ENABLED_FILE.read_text().strip() == "1" + except FileNotFoundError: + return False + + +def _set_enabled(*, enabled: bool) -> None: + """Write the enabled state to the control file.""" + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + ENABLED_FILE.write_text("1" if enabled else "0") + + +def _clamp(value: int, lo: int, hi: int) -> int: + """Clamp value between lo and hi.""" + return max(lo, min(hi, value)) + + +def main() -> None: + """Main daemon loop.""" + als_path = _find_als_device() + if als_path is None: + log.error("No ambient light sensor found. Exiting.") + sys.exit(1) + + log.info("ALS device: %s", als_path) + log.info("Control file: %s", ENABLED_FILE) + + # Ensure the control file exists (default: enabled when started as service) + if not ENABLED_FILE.exists(): + _set_enabled(enabled=True) + log.info("Created control file with auto-brightness ENABLED.") + + # Handle SIGTERM gracefully for systemd + running = True + + def _handle_signal(signum: int, _frame: object) -> None: + nonlocal running + log.info("Received signal %d, shutting down.", signum) + running = False + + signal.signal(signal.SIGTERM, _handle_signal) + signal.signal(signal.SIGINT, _handle_signal) + + log.info("Daemon started. Polling every %.1fs.", POLL_INTERVAL_S) + + while running: + try: + if _is_enabled(): + lux = _read_lux(als_path) + target = _lux_to_brightness(lux) + current = _get_brightness() + + if current >= 0 and abs(target - current) >= MIN_CHANGE_PERCENT: + # Smooth: step gradually toward the target + delta = target - current + step = _clamp(delta, -MAX_STEP_PER_TICK, MAX_STEP_PER_TICK) + new_val = _clamp(current + step, 0, 100) + _set_brightness(new_val) + log.info( + "lux=%.1f target=%d%% current=%d%% -> set %d%%", + lux, + target, + current, + new_val, + ) + except Exception: + log.exception("Error in auto-brightness loop") + + time.sleep(POLL_INTERVAL_S) + + log.info("Daemon stopped.") + + +if __name__ == "__main__": + main() diff --git a/python_pkg/brightness_controller/brightness_controller.py b/python_pkg/brightness_controller/brightness_controller.py new file mode 100755 index 0000000..78e40cf --- /dev/null +++ b/python_pkg/brightness_controller/brightness_controller.py @@ -0,0 +1,447 @@ +#!/usr/bin/env python3 +"""Lightweight GUI brightness/backlight controller using brightnessctl. + +Supports automatic brightness adjustment via ambient light sensor (IIO). +""" + +from __future__ import annotations + +from pathlib import Path +import shutil +import subprocess +import sys +import tkinter as tk +from tkinter import ttk +from typing import NamedTuple + +WINDOW_WIDTH = 420 +WINDOW_HEIGHT = 340 +SLIDER_LENGTH = 300 +POLL_INTERVAL_MS = 2000 +AUTO_POLL_MS = 1000 +STEP_PERCENT = 5 + +ICON_SUN = "\u2600" +ICON_DIM = "\u25d1" +ICON_AUTO = "\u26a1" + +CONFIG_DIR = Path.home() / ".config" / "brightness-auto" +ENABLED_FILE = CONFIG_DIR / "enabled" + +_BRIGHTNESSCTL = shutil.which("brightnessctl") or "/usr/bin/brightnessctl" + +# Minimum field counts in brightnessctl machine-readable output. +_MIN_DEVICE_LIST_FIELDS = 5 +_MIN_INFO_FIELDS = 4 + +# Lux-to-brightness mapping curve (lux_threshold, brightness_percent). +# Interpolated linearly between points. +# Covers: pitch dark → dim indoor → office → bright indoor → daylight. +LUX_CURVE: list[tuple[float, int]] = [ + (0.0, 10), + (5.0, 40), + (50.0, 75), + (200.0, 90), + (500.0, 95), + (1000.0, 100), + (5000.0, 100), +] + + +def _find_als_device() -> Path | None: + """Find the first IIO ambient light sensor device path.""" + matches = list(Path("/sys/bus/iio/devices").glob("*/in_illuminance_raw")) + if matches: + return matches[0].parent + return None + + +def _read_lux(als_path: Path) -> float: + """Read current illuminance in lux from the ALS.""" + raw = float((als_path / "in_illuminance_raw").read_text().strip()) + try: + scale = float((als_path / "in_illuminance_scale").read_text().strip()) + except (FileNotFoundError, ValueError): + scale = 1.0 + try: + offset = float((als_path / "in_illuminance_offset").read_text().strip()) + except (FileNotFoundError, ValueError): + offset = 0.0 + return (raw + offset) * scale + + +def _lux_to_brightness(lux: float) -> int: + """Map a lux reading to a screen brightness percentage using the curve.""" + if lux <= LUX_CURVE[0][0]: + return LUX_CURVE[0][1] + if lux >= LUX_CURVE[-1][0]: + return LUX_CURVE[-1][1] + + for i in range(len(LUX_CURVE) - 1): + lux_lo, bri_lo = LUX_CURVE[i] + lux_hi, bri_hi = LUX_CURVE[i + 1] + if lux_lo <= lux <= lux_hi: + ratio = (lux - lux_lo) / (lux_hi - lux_lo) + return int(bri_lo + ratio * (bri_hi - bri_lo)) + + return LUX_CURVE[-1][1] + + +class Device(NamedTuple): + """Represents a backlight/LED brightness device.""" + + name: str + device_class: str + current: int + percent: str + max_brightness: int + + +def _run_brightnessctl(*args: str) -> str: + """Run brightnessctl with given arguments and return stdout.""" + result = subprocess.run( + [_BRIGHTNESSCTL, *args], + capture_output=True, + text=True, + check=False, + ) + return result.stdout.strip() + + +def _get_devices() -> list[Device]: + """List available brightness devices using machine-readable output. + + Only returns backlight devices (filters out keyboard LEDs, LAN LEDs, etc.). + """ + output = _run_brightnessctl("-l", "-m") + devices: list[Device] = [] + for line in output.splitlines(): + parts = line.split(",") + if len(parts) >= _MIN_DEVICE_LIST_FIELDS and parts[1] == "backlight": + devices.append( + Device( + name=parts[0], + device_class=parts[1], + current=int(parts[3].rstrip("%")), + percent=parts[3], + max_brightness=int(parts[4]), + ) + ) + return devices + + +def _get_brightness(device: str) -> int: + """Get current brightness percentage for a device.""" + output = _run_brightnessctl("-d", device, "-m", "get") + if not output: + return -1 + # Machine-readable "get" returns just the raw value; use "info" instead + info = _run_brightnessctl("-d", device, "-m", "info") + for line in info.splitlines(): + parts = line.split(",") + if len(parts) >= _MIN_INFO_FIELDS: + return int(parts[3].rstrip("%")) + return -1 + + +def _set_brightness(device: str, percent: int) -> None: + """Set brightness to a percentage for a device.""" + _run_brightnessctl("-d", device, "set", f"{percent}%") + + +class BrightnessController: + """Main GUI application for controlling brightness.""" + + def __init__(self) -> None: + """Initialize the brightness controller GUI.""" + self.root = tk.Tk() + self.root.title(f"{ICON_SUN} Brightness Controller") + self.root.geometry(f"{WINDOW_WIDTH}x{WINDOW_HEIGHT}") + self.root.resizable(width=False, height=False) + self.root.configure(bg="#2b2b2b") + + self._updating_slider = False + self.devices = _get_devices() + self.current_device: str = "" + + # Ambient light sensor + self.als_path = _find_als_device() + self.auto_mode = self._read_daemon_state() + + self._build_ui() + self._select_default_device() + self._sync_auto_ui() + self._poll_brightness() + if self.als_path: + self._poll_als() + + def _build_ui(self) -> None: + """Build the entire UI layout.""" + style = ttk.Style() + style.theme_use("clam") + style.configure("TFrame", background="#2b2b2b") + style.configure( + "TLabel", + background="#2b2b2b", + foreground="#e0e0e0", + font=("sans-serif", 11), + ) + style.configure( + "Title.TLabel", + background="#2b2b2b", + foreground="#f0c040", + font=("sans-serif", 14, "bold"), + ) + style.configure( + "Pct.TLabel", + background="#2b2b2b", + foreground="#ffffff", + font=("sans-serif", 28, "bold"), + ) + style.configure("TButton", font=("sans-serif", 12), padding=6) + style.configure("Horizontal.TScale", background="#2b2b2b") + + main = ttk.Frame(self.root, padding=16) + main.pack(fill=tk.BOTH, expand=True) + + # Title row + title_frame = ttk.Frame(main) + title_frame.pack(fill=tk.X, pady=(0, 8)) + + ttk.Label( + title_frame, text=f"{ICON_SUN} Brightness Controller", style="Title.TLabel" + ).pack(side=tk.LEFT, fill=tk.X, expand=True) + + # Device selector (only shown if multiple backlight devices exist) + device_names = [d.name for d in self.devices] + self.device_var = tk.StringVar() + + if len(device_names) > 1: + device_frame = ttk.Frame(main) + device_frame.pack(fill=tk.X, pady=(0, 8)) + ttk.Label(device_frame, text="Device:").pack(side=tk.LEFT, padx=(0, 8)) + self.device_combo = ttk.Combobox( + device_frame, + textvariable=self.device_var, + values=device_names, + state="readonly", + width=30, + ) + self.device_combo.pack(side=tk.LEFT, fill=tk.X, expand=True) + self.device_combo.bind("<>", self._on_device_change) + + # Percentage display + self.pct_var = tk.StringVar(value="—%") + ttk.Label(main, textvariable=self.pct_var, style="Pct.TLabel").pack(pady=(4, 2)) + + # Slider row + slider_frame = ttk.Frame(main) + slider_frame.pack(fill=tk.X, pady=4) + + ttk.Label(slider_frame, text=ICON_DIM, font=("sans-serif", 16)).pack( + side=tk.LEFT, padx=(0, 6) + ) + + self.slider_var = tk.IntVar(value=50) + self.slider = ttk.Scale( + slider_frame, + from_=0, + to=100, + orient=tk.HORIZONTAL, + variable=self.slider_var, + length=SLIDER_LENGTH, + command=self._on_slider_move, + ) + self.slider.pack(side=tk.LEFT, fill=tk.X, expand=True, padx=4) + + ttk.Label(slider_frame, text=ICON_SUN, font=("sans-serif", 16)).pack( + side=tk.LEFT, padx=(6, 0) + ) + + # Buttons row + btn_frame = ttk.Frame(main) + btn_frame.pack(fill=tk.X, pady=(8, 0)) + + ttk.Button(btn_frame, text=f"- {STEP_PERCENT}%", command=self._decrease).pack( + side=tk.LEFT, padx=(0, 4) + ) + ttk.Button(btn_frame, text="25%", command=lambda: self._set_pct(25)).pack( + side=tk.LEFT, padx=4 + ) + ttk.Button(btn_frame, text="50%", command=lambda: self._set_pct(50)).pack( + side=tk.LEFT, padx=4 + ) + ttk.Button(btn_frame, text="75%", command=lambda: self._set_pct(75)).pack( + side=tk.LEFT, padx=4 + ) + ttk.Button(btn_frame, text="100%", command=lambda: self._set_pct(100)).pack( + side=tk.LEFT, padx=4 + ) + ttk.Button(btn_frame, text=f"+ {STEP_PERCENT}%", command=self._increase).pack( + side=tk.LEFT, padx=(4, 0) + ) + + # Auto-brightness row (only shown if ALS is available) + if self.als_path: + auto_frame = ttk.Frame(main) + auto_frame.pack(fill=tk.X, pady=(10, 0)) + + self.auto_btn_var = tk.StringVar(value=f"{ICON_AUTO} Auto: OFF") + self.auto_btn = ttk.Button( + auto_frame, textvariable=self.auto_btn_var, command=self._toggle_auto + ) + self.auto_btn.pack(side=tk.LEFT, padx=(0, 10)) + + self.lux_var = tk.StringVar(value="") + ttk.Label(auto_frame, textvariable=self.lux_var).pack(side=tk.LEFT) + + def _select_default_device(self) -> None: + """Auto-select the first backlight device, or first device overall.""" + if not self.devices: + self.pct_var.set("No devices") + return + + # Prefer backlight devices + default = self.devices[0] + for dev in self.devices: + if dev.device_class == "backlight": + default = dev + break + + self.device_var.set(default.name) + self.current_device = default.name + self._refresh_brightness() + + def _on_device_change(self, _event: tk.Event) -> None: # type: ignore[type-arg] + """Handle device selection change.""" + self.current_device = self.device_var.get() + self._refresh_brightness() + + def _refresh_brightness(self) -> None: + """Read current brightness and update UI.""" + if not self.current_device: + return + pct = _get_brightness(self.current_device) + if pct < 0: + self.pct_var.set("Error") + return + self._updating_slider = True + self.slider_var.set(pct) + self._updating_slider = False + self.pct_var.set(f"{pct}%") + + def _on_slider_move(self, value: str) -> None: + """Handle slider drag — set brightness in real time.""" + if self._updating_slider or not self.current_device: + return + # Manual slider movement disables auto mode + if self.auto_mode: + self._set_auto(enabled=False) + pct = int(float(value)) + self.pct_var.set(f"{pct}%") + _set_brightness(self.current_device, pct) + + def _set_pct(self, percent: int) -> None: + """Set brightness to an exact percentage.""" + if not self.current_device: + return + _set_brightness(self.current_device, percent) + self._refresh_brightness() + + def _decrease(self) -> None: + """Decrease brightness by the step amount.""" + if not self.current_device: + return + current = _get_brightness(self.current_device) + new_val = max(0, current - STEP_PERCENT) + _set_brightness(self.current_device, new_val) + self._refresh_brightness() + + def _increase(self) -> None: + """Increase brightness by the step amount.""" + if not self.current_device: + return + current = _get_brightness(self.current_device) + new_val = min(100, current + STEP_PERCENT) + _set_brightness(self.current_device, new_val) + self._refresh_brightness() + + def _toggle_auto(self) -> None: + """Toggle automatic brightness mode via the daemon control file.""" + self._set_auto(enabled=not self.auto_mode) + + @staticmethod + def _read_daemon_state() -> bool: + """Read the daemon's enabled state from the control file.""" + try: + return ENABLED_FILE.read_text().strip() == "1" + except FileNotFoundError: + return False + + def _set_auto(self, *, enabled: bool) -> None: + """Enable or disable automatic brightness mode via the daemon.""" + self.auto_mode = enabled + # Write to the shared control file so the daemon picks it up + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + ENABLED_FILE.write_text("1" if enabled else "0") + self._sync_auto_ui() + + def _sync_auto_ui(self) -> None: + """Update the auto button and slider state to match current mode.""" + if not self.als_path: + return + if self.auto_mode: + self.auto_btn_var.set(f"{ICON_AUTO} Auto: ON") + self.slider.state(["disabled"]) + else: + self.auto_btn_var.set(f"{ICON_AUTO} Auto: OFF") + self.slider.state(["!disabled"]) + + def _poll_als(self) -> None: + """Read the ambient light sensor and display lux. Sync UI with daemon state.""" + if self.als_path: + try: + lux = _read_lux(self.als_path) + self.lux_var.set(f"{ICON_SUN} {lux:.1f} lux") + except (OSError, ValueError): + self.lux_var.set("sensor error") + # Sync auto mode from daemon control file (in case changed externally) + daemon_state = self._read_daemon_state() + if daemon_state != self.auto_mode: + self.auto_mode = daemon_state + self._sync_auto_ui() + self.root.after(AUTO_POLL_MS, self._poll_als) + + def _poll_brightness(self) -> None: + """Periodically sync brightness from the system (for external changes).""" + if not self.auto_mode: + self._refresh_brightness() + self.root.after(POLL_INTERVAL_MS, self._poll_brightness) + + def run(self) -> None: + """Start the main event loop.""" + self.root.mainloop() + + +def main() -> None: + """Entry point.""" + # Quick check for brightnessctl + try: + subprocess.run( + [_BRIGHTNESSCTL, "--version"], + capture_output=True, + check=True, + ) + except FileNotFoundError: + sys.stderr.write( + "Error: brightnessctl not found." + " Install it with: sudo pacman -S brightnessctl\n" + ) + sys.exit(1) + + app = BrightnessController() + app.run() + + +if __name__ == "__main__": + main()