mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 17:03:05 +02:00
- Remove all # type: ignore and # noqa comments (banned by no-noqa hook) - Add mypy --disable-error-code flags to pre-commit config for error codes previously suppressed by inline comments - Fix broken imports after ruff auto-removed re-exports: steam_backlog_enforcer, stockfish_analysis, word_frequency, lichess_bot - Re-add re-exports with __all__ in translator.py, screen_lock.py - Split _process_epc_fc.py (524 lines) into _process_epc_fc.py + _process_fc.py - Fix test failures: keyboard_coop, stockfish_analysis, tag_divider - Add per-file-ignores for PLC0415 (deferred imports) in 7 files - Mark shebang scripts as executable - Add __init__.py for generate_images and repo_explorer packages - Fix codespell, eslint, ruff-format, prettier issues - Update copilot-instructions.md with --no-verify ban
448 lines
14 KiB
Python
Executable File
448 lines
14 KiB
Python
Executable File
#!/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("<<ComboboxSelected>>", 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:
|
|
"""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()
|