commit d88a254fbb0d42343c4e15b6c646ea30868ead99 Author: pongsn Date: Mon Jun 15 02:17:40 2026 -0500 Initial commit: wallaura — KDE wallpaper → keyboard LED sync Daemon that listens for KDE Plasma wallpaperChanged D-Bus signals, splits the wallpaper into 4 vertical zones, extracts a dominant color per zone via median-cut quantization, and applies the colors to the keyboard's 4 LED zones via legionaura static. Includes software brightness scaling (default 67%), a config file at ~/.config/wallaura/config.json with hot-reload, CLI commands for getting/setting brightness, and a systemd user service for autostart. Co-Authored-By: Claude Sonnet 4.6 diff --git a/README.md b/README.md new file mode 100644 index 0000000..8ba0c46 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# wallaura + +Automatically syncs your Lenovo Legion / LOQ keyboard LED zone colors with your KDE Plasma desktop wallpaper. + +When the wallpaper changes — whether you change it manually or a slideshow rotates — wallaura analyzes the image, extracts a dominant color from each of its four horizontal sections, and applies those colors to the keyboard's four LED zones in real time. + +--- + +## How it works + +1. **Detects wallpaper changes** by listening to the `org.kde.PlasmaShell.wallpaperChanged` D-Bus signal emitted by KDE Plasma. This covers both manual changes and automatic slideshow rotations. +2. **Resolves the image file** via the PlasmaShell D-Bus API. KDE wallpaper packages (directories like `MilkyWay/`) are handled automatically by picking the highest-resolution image inside `contents/images/`. +3. **Splits the image** into four equal vertical strips corresponding to the keyboard's four LED zones (left → right). +4. **Extracts a dominant color** from each strip using median-cut quantization (via Pillow), selecting the color that covers the most pixels rather than a blended average. +5. **Scales the colors** by the configured brightness factor (default 67%) and sends them to the keyboard via `legionaura static --brightness 2`. + +--- + +## Requirements + +- **KDE Plasma** desktop (Wayland or X11) +- **[LegionAura](https://github.com/nivedck/LegionAura)** — the `legionaura` CLI must be installed and working +- **Python 3.10+** +- Python packages: `dbus-python`, `PyGObject`, `Pillow` + +On Arch-based systems: + +```bash +sudo pacman -S python-dbus python-gobject python-pillow +``` + +On Debian/Ubuntu: + +```bash +sudo apt install python3-dbus python3-gi python3-pil +``` + +--- + +## Installation + +### 1. Clone the repository + +```bash +git clone ~/tst/wallaura +cd ~/tst/wallaura +``` + +### 2. Make the script executable + +```bash +chmod +x wallaura.py +``` + +### 3. Add to PATH + +```bash +ln -sf ~/tst/wallaura/wallaura.py ~/.local/bin/wallaura +``` + +Verify it is found: + +```bash +wallaura --get-brightness +``` + +### 4. Install the systemd user service + +```bash +cp wallaura.service ~/.config/systemd/user/ +systemctl --user daemon-reload +systemctl --user enable --now wallaura.service +``` + +The service starts automatically on graphical login. + +--- + +## Usage + +### Daemon + +The daemon is managed via systemctl: + +```bash +systemctl --user start wallaura # start +systemctl --user stop wallaura # stop +systemctl --user restart wallaura # restart (pick up code changes) +systemctl --user status wallaura # show running status +journalctl --user -u wallaura -f # live log output +``` + +### Brightness + +wallaura applies software brightness scaling to the extracted colors, giving smooth control across the full 0–100% range. + +The default brightness is **67%** (approximately two-thirds of maximum). + +```bash +wallaura --set-brightness 80% # set to 80% +wallaura --set-brightness 0.5 # set to 50% (decimal form) +wallaura --set-brightness 50 # set to 50% (plain integer) +wallaura --get-brightness # print current brightness +``` + +The brightness setting is saved to `~/.config/wallaura/config.json`. The running daemon detects the change within five seconds and re-applies the current wallpaper colors at the new brightness — no restart needed. + +--- + +## Configuration + +Settings are stored in `~/.config/wallaura/config.json`: + +```json +{ + "brightness": 0.6667 +} +``` + +This file can be edited by hand. The daemon polls it every five seconds and reloads it automatically when it changes. + +--- + +## Troubleshooting + +**`legionaura` command not found** +Install LegionAura and ensure the `legionaura` binary is in your PATH. See [LegionAura's README](https://github.com/nivedck/LegionAura) for installation instructions. + +**Colors are not applied / keyboard does not respond** +Run `legionaura static ff0000 ff0000 ff0000 ff0000` manually. If that fails, the issue is with LegionAura or USB permissions, not wallaura. + +**Slideshow changes are not detected** +Ensure slideshow is configured through KDE's desktop settings (right-click desktop → Configure Desktop). wallaura relies on the `wallpaperChanged` D-Bus signal from PlasmaShell, which is only emitted when Plasma manages the slideshow. + +**Service fails to start** +Check the logs with `journalctl --user -u wallaura -f`. The most common cause is a missing Python package; install the dependencies listed in the Requirements section. + +--- + +## License + +MIT diff --git a/wallaura.py b/wallaura.py new file mode 100755 index 0000000..b9039b6 --- /dev/null +++ b/wallaura.py @@ -0,0 +1,322 @@ +#!/usr/bin/env python3 +"""wallaura — sync keyboard LED zones with the current KDE Plasma wallpaper. + +Daemon mode (default): + Listens for wallpaperChanged D-Bus signals from PlasmaShell, analyzes the + wallpaper by splitting it into 4 equal vertical strips, extracts a dominant + color per strip using median-cut quantization, scales each color by the + configured brightness, and applies the 4 colors via `legionaura static`. + +CLI modes: + wallaura --set-brightness write brightness to config and exit + value: 0.0–1.0 or 0–100 or 0–100% + wallaura --get-brightness print current brightness and exit +""" + +import json +import os +import subprocess +import logging +import sys +from pathlib import Path +from urllib.parse import urlparse, unquote + +import dbus +import dbus.mainloop.glib +from gi.repository import GLib +from PIL import Image + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s %(levelname)-7s %(message)s', + datefmt='%H:%M:%S', +) +log = logging.getLogger('wallaura') + +LEGIONAURA = 'legionaura' +SCREEN = 0 +DEFAULT_BRIGHTNESS = 2 / 3 # ≈ 0.667 +CONFIG_PATH = Path.home() / '.config' / 'wallaura' / 'config.json' + +# Module-level state updated by the daemon loop +_brightness: float = DEFAULT_BRIGHTNESS +_config_mtime: float = 0.0 +_last_colors: list[tuple[int, int, int]] | None = None + + +# --------------------------------------------------------------------------- +# Config persistence +# --------------------------------------------------------------------------- + +def load_config() -> None: + """Read config.json and update the module-level brightness.""" + global _brightness, _config_mtime + try: + data = json.loads(CONFIG_PATH.read_text()) + _brightness = float(data.get('brightness', DEFAULT_BRIGHTNESS)) + _brightness = max(0.0, min(1.0, _brightness)) + _config_mtime = CONFIG_PATH.stat().st_mtime + log.debug('Config loaded: brightness=%.3f', _brightness) + except FileNotFoundError: + pass # use default — no file yet + except Exception as exc: + log.warning('Could not read config: %s', exc) + + +def save_config(brightness: float) -> None: + """Write brightness to config.json, creating the directory if needed.""" + CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True) + CONFIG_PATH.write_text(json.dumps({'brightness': round(brightness, 4)}, indent=2) + '\n') + + +def _parse_brightness(value: str) -> float: + """Parse a user-supplied brightness value into a float in [0.0, 1.0].""" + value = value.strip().rstrip('%') + f = float(value) + if f > 1.0: # treat as percentage (0–100) + f /= 100.0 + if not 0.0 <= f <= 1.0: + raise ValueError(f'brightness must be between 0 and 100 (got {value})') + return f + + +# --------------------------------------------------------------------------- +# Wallpaper path resolution +# --------------------------------------------------------------------------- + +def _best_image_in_dir(directory: Path) -> Path | None: + """Return the largest image inside a KDE wallpaper package or plain directory.""" + # KDE wallpaper package: /contents/images/.png + package_images = directory / 'contents' / 'images' + search_dir = package_images if package_images.is_dir() else directory + + IMAGE_EXTS = {'.png', '.jpg', '.jpeg', '.webp', '.avif'} + candidates = [ + p for p in search_dir.iterdir() + if p.is_file() and p.suffix.lower() in IMAGE_EXTS + ] + return max(candidates, key=lambda p: p.stat().st_size) if candidates else None + + +def get_wallpaper_path() -> Path | None: + """Return the filesystem path of the currently displayed wallpaper image.""" + try: + bus = dbus.SessionBus() + ps = bus.get_object('org.kde.plasmashell', '/PlasmaShell') + iface = dbus.Interface(ps, 'org.kde.PlasmaShell') + params = dict(iface.wallpaper(SCREEN)) + image_uri = str(params.get('Image', '')) + except dbus.DBusException as exc: + log.warning('D-Bus error getting wallpaper params: %s', exc) + return None + + if not image_uri or image_uri == 'null': + log.warning('wallpaper() returned empty Image field') + return None + + path = Path(unquote(urlparse(image_uri).path)) + + if path.is_file(): + return path + if path.is_dir(): + resolved = _best_image_in_dir(path) + if resolved: + return resolved + log.warning('No images found in wallpaper directory: %s', path) + else: + log.warning('Cannot resolve image path from URI: %s', image_uri) + return None + + +# --------------------------------------------------------------------------- +# Image analysis: 4 vertical zones → dominant color each +# --------------------------------------------------------------------------- + +def _dominant_color(region: Image.Image) -> tuple[int, int, int]: + """Return the most visually prominent RGB color in an image region. + + Downscales the region, runs median-cut quantization to 5 colors, and picks + the one covering the most pixels. + """ + small = region.resize((100, 100), Image.LANCZOS).convert('RGB') + + NUM_COLORS = 5 + quantized = small.quantize(colors=NUM_COLORS, method=Image.Quantize.MEDIANCUT) + palette = quantized.getpalette()[:NUM_COLORS * 3] + + counts = [0] * NUM_COLORS + for idx in quantized.get_flattened_data(): + counts[idx] += 1 + + best = counts.index(max(counts)) + return palette[best * 3], palette[best * 3 + 1], palette[best * 3 + 2] + + +def analyze_wallpaper(path: Path) -> list[tuple[int, int, int]]: + """Split the wallpaper into 4 vertical strips and return one color per strip.""" + img = Image.open(path).convert('RGB') + w, h = img.size + zone_w = w // 4 + colors: list[tuple[int, int, int]] = [] + + for zone in range(4): + x0 = zone * zone_w + x1 = w if zone == 3 else (zone + 1) * zone_w + colors.append(_dominant_color(img.crop((x0, 0, x1, h)))) + + return colors + + +# --------------------------------------------------------------------------- +# Apply colors to keyboard via legionaura CLI +# --------------------------------------------------------------------------- + +def _scale(channel: int, factor: float) -> int: + return round(channel * factor) + + +def apply_colors(colors: list[tuple[int, int, int]], brightness: float | None = None) -> None: + """Scale colors by brightness and call `legionaura static --brightness 2`. + + Hardware brightness is pinned to 2 (max) so that wallaura's software + scaling has the full dynamic range. + """ + if brightness is None: + brightness = _brightness + + scaled = [ + (_scale(r, brightness), _scale(g, brightness), _scale(b, brightness)) + for r, g, b in colors + ] + hex_colors = [f'{r:02x}{g:02x}{b:02x}' for r, g, b in scaled] + cmd = [LEGIONAURA, 'static'] + hex_colors + ['--brightness', '2'] + log.info('brightness=%.0f%% zones=%s', brightness * 100, ' '.join(hex_colors)) + + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + log.error('legionaura failed (exit %d): %s', result.returncode, result.stderr.strip()) + + +# --------------------------------------------------------------------------- +# Main update cycle +# --------------------------------------------------------------------------- + +def update() -> None: + """Detect current wallpaper, analyze it, and push scaled colors to keyboard.""" + global _last_colors + + path = get_wallpaper_path() + if path is None: + log.warning('No wallpaper image resolved — skipping update') + return + + log.info('Wallpaper: %s', path) + try: + colors = analyze_wallpaper(path) + _last_colors = colors + apply_colors(colors) + except Exception as exc: + log.error('Error processing %s: %s', path, exc) + + +def _reapply_with_current_brightness() -> None: + """Re-apply last known colors with the updated brightness — no image re-read.""" + if _last_colors: + apply_colors(_last_colors) + else: + update() + + +# --------------------------------------------------------------------------- +# Config file hot-reload (polled every 5 s via GLib timer) +# --------------------------------------------------------------------------- + +def _poll_config() -> bool: + """GLib timer callback: reload config if the file changed, then re-apply.""" + global _config_mtime + try: + mtime = CONFIG_PATH.stat().st_mtime + if mtime != _config_mtime: + log.info('Config changed — reloading brightness') + load_config() + _reapply_with_current_brightness() + except FileNotFoundError: + pass + return True # keep the timer alive + + +# --------------------------------------------------------------------------- +# D-Bus signal handler +# --------------------------------------------------------------------------- + +def _on_wallpaper_changed(screen_num: int) -> None: + log.info('wallpaperChanged signal (screen %s)', screen_num) + update() + + +# --------------------------------------------------------------------------- +# Daemon entry point +# --------------------------------------------------------------------------- + +def run_daemon() -> None: + load_config() + log.info('wallaura started brightness=%.0f%%', _brightness * 100) + + dbus.mainloop.glib.DBusGMainLoop(set_as_default=True) + bus = dbus.SessionBus() + + bus.add_signal_receiver( + _on_wallpaper_changed, + signal_name='wallpaperChanged', + dbus_interface='org.kde.PlasmaShell', + path='/PlasmaShell', + ) + + update() # apply immediately on startup + + GLib.timeout_add_seconds(5, _poll_config) + GLib.MainLoop().run() + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + +def main() -> None: + args = sys.argv[1:] + + if not args: + run_daemon() + return + + if args[0] == '--get-brightness': + load_config() + print(f'{_brightness * 100:.1f}% ({_brightness:.4f})') + return + + if args[0] == '--set-brightness': + if len(args) < 2: + print('Usage: wallaura --set-brightness ', file=sys.stderr) + print(' value: 0–100, 0.0–1.0, or 0–100%', file=sys.stderr) + sys.exit(1) + try: + brightness = _parse_brightness(args[1]) + except ValueError as exc: + print(f'Error: {exc}', file=sys.stderr) + sys.exit(1) + + save_config(brightness) + print(f'Brightness set to {brightness * 100:.1f}%') + print(f'(The running daemon picks this up within 5 seconds.)') + return + + print(f'Unknown argument: {args[0]}', file=sys.stderr) + print('Usage:', file=sys.stderr) + print(' wallaura run as daemon', file=sys.stderr) + print(' wallaura --set-brightness set brightness (0–100%)', file=sys.stderr) + print(' wallaura --get-brightness show current brightness', file=sys.stderr) + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/wallaura.service b/wallaura.service new file mode 100644 index 0000000..06febbc --- /dev/null +++ b/wallaura.service @@ -0,0 +1,16 @@ +[Unit] +Description=wallaura — sync keyboard LED zones with desktop wallpaper +Documentation=https://github.com/nivedck/LegionAura +After=graphical-session.target +PartOf=graphical-session.target + +[Service] +Type=simple +ExecStart=%h/tst/wallaura/wallaura.py +Restart=on-failure +RestartSec=5 +# Forward journal logs with a tag +SyslogIdentifier=wallaura + +[Install] +WantedBy=graphical-session.target