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 <noreply@anthropic.com>
This commit is contained in:
pongsn 2026-06-15 02:17:40 -05:00
commit d88a254fbb
3 changed files with 480 additions and 0 deletions

142
README.md Normal file
View file

@ -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 <c1> <c2> <c3> <c4> --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 <repo-url> ~/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

322
wallaura.py Executable file
View file

@ -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 <value> 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: <name>/contents/images/<res>.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 <c1..c4> --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 <value>', 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 <val> set brightness (0–100%)', file=sys.stderr)
print(' wallaura --get-brightness show current brightness', file=sys.stderr)
sys.exit(1)
if __name__ == '__main__':
main()

16
wallaura.service Normal file
View file

@ -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