diff --git a/Makefile b/Makefile index 4dd5ea8..1a1cd34 100644 --- a/Makefile +++ b/Makefile @@ -2,10 +2,10 @@ all: build # rgb: -# clang++ -std=c++23 ./rgb.cpp -o ./rgb +# clang++-14 -std=c++23 ./rgb.cpp -o ./rgb acer-rgb-cli: - clang++ -std=c++23 acer-rgb-cli.cpp -o acer-rgb-cli + clang++-14 -std=c++23 acer-rgb-cli.cpp -o acer-rgb-cli build-cli: acer-rgb-cli @@ -13,7 +13,7 @@ clean-cli: rm -f acer-rgb-cli acer-rgbd: - g++ -O2 -std=c++23 acer-rgbd.cpp -o acer-rgbd + g++-14 -O2 -std=c++23 acer-rgbd.cpp -o acer-rgbd build: acer-rgbd diff --git a/README.md b/README.md index 08bdd3d..ccb49db 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # acer-lighting +> **Fork Notice:** This is a fork of [fcrespo82/acer-lighting-daemon](https://github.com/fcrespo82/acer-lighting-daemon) with added GUI tray applet support. + **Thanks** This project benefited greatly from the work, testing and research of several community contributors — especially: @ZoeBattleSand, @0x189D7997, and @JakeBrxwn. Their reverse-engineering, testing, tooling and discussion (see https://github.com/0x7375646F/Linuwu-Sense/pull/65) made much of this possible. @@ -7,7 +9,7 @@ This project benefited greatly from the work, testing and research of several co Small tools to control Acer laptop RGB zones and a daemon that persists/apply states. > [!CAUTION] -> The code here was based on the work of others but it was written with the help of AI too +> The code here was based on the work of others but it was written with the help of AI too ## Build @@ -48,6 +50,41 @@ If you need to undo the install: sudo make uninstall ``` +## GUI Tray Applet + +A system tray applet is included for easy RGB control without using the command line. + +### Dependencies + +```sh +sudo apt install python3-gi gir1.2-appindicator3-0.1 +``` + +### Running + +```sh +python3 acer-rgb-tray.py +``` + +The applet appears in your system tray. Click it to access: +- **Color presets** (Red, Green, Blue, White, Cyan, Magenta, Yellow, Orange) +- **Custom...** - Opens a full color picker dialog +- **Effect** submenu - Static, Breathing, Neon, Wave, Ripple, Zoom, Snake, Disco, Shifting + +### Autostart + +To start the applet automatically on login: + +```sh +cp acer-rgb-tray.desktop ~/.config/autostart/ +``` + +Make sure the daemon is running before the applet starts: + +```sh +sudo systemctl enable acer-rgbd.service +``` + ## Usage - Send commands to the daemon using the `acer-rgb` helper (it talks to the daemon socket): @@ -85,3 +122,4 @@ sudo systemctl restart acer-rgbd.service - The installer writes an initial all-green state (keyboard/lid/button) to `/var/lib/acer-rgbd/state.txt` so newly installed systems show green LEDs by default. - Running the daemon requires root privileges (or appropriate udev rules) to access the HID device. +- The GUI applet communicates with the daemon via socket (`/run/acer-rgbd.sock`) and runs as a regular user. diff --git a/__pycache__/acer-rgb-tray.cpython-312.pyc b/__pycache__/acer-rgb-tray.cpython-312.pyc new file mode 100644 index 0000000..d2b5c08 Binary files /dev/null and b/__pycache__/acer-rgb-tray.cpython-312.pyc differ diff --git a/__pycache__/daemon_client.cpython-312.pyc b/__pycache__/daemon_client.cpython-312.pyc new file mode 100644 index 0000000..9cd5b01 Binary files /dev/null and b/__pycache__/daemon_client.cpython-312.pyc differ diff --git a/acer-rgb-tray.desktop b/acer-rgb-tray.desktop new file mode 100644 index 0000000..0cb6146 --- /dev/null +++ b/acer-rgb-tray.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Acer RGB Control +Comment=Control Acer keyboard RGB lighting +Exec=/home/ncode/Dev/acer-lighting-daemon/acer-rgb-tray.py +Icon=input-keyboard +Terminal=false +Categories=Utility;Settings; +StartupNotify=false diff --git a/acer-rgb-tray.py b/acer-rgb-tray.py new file mode 100755 index 0000000..be040b6 --- /dev/null +++ b/acer-rgb-tray.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""Acer RGB Keyboard Tray Applet - Menu-based controls.""" + +import gi +gi.require_version("Gtk", "3.0") +gi.require_version("AppIndicator3", "0.1") + +from gi.repository import Gtk, AppIndicator3, Gdk + +from daemon_client import ( + RGBState, EFFECTS, + is_daemon_running, get_states, set_rgb +) + +# Color presets (name, r, g, b) +COLOR_PRESETS = [ + ("Red", 255, 0, 0), + ("Green", 0, 255, 0), + ("Blue", 0, 0, 255), + ("White", 255, 255, 255), + ("Cyan", 0, 255, 255), + ("Magenta", 255, 0, 255), + ("Yellow", 255, 255, 0), + ("Orange", 255, 128, 0), +] + + +class AcerRGBTray: + """System tray application with menu-based controls.""" + + def __init__(self): + self.current_state = None + + self.indicator = AppIndicator3.Indicator.new( + "acer-rgb-tray", + "media-view-subtitles-symbolic", + AppIndicator3.IndicatorCategory.HARDWARE + ) + self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE) + + self.load_state() + self.build_menu() + + def build_menu(self): + """Build the dropdown menu with all controls.""" + menu = Gtk.Menu() + + # Status line at top + self.status_item = Gtk.MenuItem(label="Loading...") + self.status_item.set_sensitive(False) + menu.append(self.status_item) + + menu.append(Gtk.SeparatorMenuItem()) + + # Color presets directly in menu + for name, r, g, b in COLOR_PRESETS: + item = Gtk.MenuItem(label=name) + item.connect("activate", self.on_color_preset, r, g, b) + menu.append(item) + + # Custom color option + custom_item = Gtk.MenuItem(label="Custom...") + custom_item.connect("activate", self.on_custom_color) + menu.append(custom_item) + + menu.append(Gtk.SeparatorMenuItem()) + + # Effect submenu + effect_item = Gtk.MenuItem(label="Effect") + effect_submenu = Gtk.Menu() + self.effect_group = [] + for effect_id, effect_name in EFFECTS: + item = Gtk.RadioMenuItem(label=effect_name) + if self.effect_group: + item.set_property("group", self.effect_group[0]) + self.effect_group.append(item) + item.connect("toggled", self.on_effect_changed, effect_id) + effect_submenu.append(item) + effect_item.set_submenu(effect_submenu) + menu.append(effect_item) + + menu.show_all() + self.indicator.set_menu(menu) + self.update_ui_from_state() + + def load_state(self): + """Load current state from daemon.""" + if not is_daemon_running(): + self.current_state = RGBState(device="keyboard") + return + + try: + states = get_states() + if "keyboard" in states: + self.current_state = states["keyboard"] + else: + self.current_state = RGBState(device="keyboard") + except Exception: + self.current_state = RGBState(device="keyboard") + + def update_ui_from_state(self): + """Update menu items to reflect current state.""" + if self.current_state is None: + self.status_item.set_label("Daemon not running") + return + + state = self.current_state + + # Update status line + color_name = self.get_color_name(state.r, state.g, state.b) + effect_name = dict(EFFECTS).get(state.effect, state.effect) + self.status_item.set_label(f"{color_name} | {effect_name}") + + # Update effect radio buttons + for i, (effect_id, _) in enumerate(EFFECTS): + if effect_id == state.effect: + self.effect_group[i].set_active(True) + break + + def get_color_name(self, r, g, b): + """Get color name from RGB values, or hex if not a preset.""" + for name, pr, pg, pb in COLOR_PRESETS: + if (r, g, b) == (pr, pg, pb): + return name + return f"#{r:02X}{g:02X}{b:02X}" + + def on_color_preset(self, item, r, g, b): + """Handle color preset selection.""" + self.current_state.r = r + self.current_state.g = g + self.current_state.b = b + self.apply_state() + + def on_custom_color(self, item): + """Show color chooser dialog.""" + dialog = Gtk.ColorChooserDialog( + title="Choose Color", + transient_for=None + ) + dialog.set_use_alpha(False) + dialog.set_rgba(Gdk.RGBA( + self.current_state.r / 255, + self.current_state.g / 255, + self.current_state.b / 255, + 1.0 + )) + + if dialog.run() == Gtk.ResponseType.OK: + rgba = dialog.get_rgba() + self.current_state.r = int(rgba.red * 255) + self.current_state.g = int(rgba.green * 255) + self.current_state.b = int(rgba.blue * 255) + self.apply_state() + + dialog.destroy() + + def on_effect_changed(self, item, effect_id): + """Handle effect selection.""" + if not item.get_active(): + return + self.current_state.effect = effect_id + if effect_id == "static": + self.current_state.speed = 0 + else: + self.current_state.speed = 5 + self.apply_state() + + def apply_state(self): + """Apply current state to daemon.""" + self.current_state.device = "keyboard" + success, msg = set_rgb(self.current_state) + if success: + self.update_ui_from_state() + else: + self.status_item.set_label(f"Error: {msg}") + + def run(self): + Gtk.main() + + +if __name__ == "__main__": + app = AcerRGBTray() + app.run() diff --git a/daemon_client.py b/daemon_client.py new file mode 100644 index 0000000..cb7089d --- /dev/null +++ b/daemon_client.py @@ -0,0 +1,118 @@ +"""Socket client for acer-rgbd daemon communication.""" + +import socket +from dataclasses import dataclass +from typing import Optional + +SOCKET_PATH = "/run/acer-rgbd.sock" + +EFFECTS = [ + ("static", "Static"), + ("breathing", "Breathing"), + ("neon", "Neon"), + ("wave", "Wave"), + ("ripple", "Ripple"), + ("zoom", "Zoom"), + ("snake", "Snake"), + ("disco", "Disco"), + ("shifting", "Shifting"), +] + +DEVICES = ["keyboard", "lid", "button"] + + +@dataclass +class RGBState: + device: str = "keyboard" + hidraw: str = "/dev/hidraw4" + effect: str = "static" + brightness: int = 100 + speed: int = 0 + direction: str = "none" + r: int = 255 + g: int = 255 + b: int = 255 + zone: str = "all" + + def to_command(self) -> str: + return ( + f"SET dev={self.device} hidraw={self.hidraw} " + f"effect={self.effect} bright={self.brightness} " + f"speed={self.speed} dir={self.direction} " + f"r={self.r} g={self.g} b={self.b} zone={self.zone}" + ) + + @classmethod + def from_line(cls, line: str) -> "RGBState": + state = cls() + for pair in line.split(): + if "=" not in pair: + continue + key, val = pair.split("=", 1) + if key == "dev": + state.device = val + elif key == "hidraw": + state.hidraw = val + elif key == "effect": + state.effect = val + elif key == "bright": + state.brightness = int(val) + elif key == "speed": + state.speed = int(val) + elif key == "dir": + state.direction = val + elif key == "r": + state.r = int(val) + elif key == "g": + state.g = int(val) + elif key == "b": + state.b = int(val) + elif key == "zone": + state.zone = val + return state + + +def send_command(cmd: str) -> str: + """Send command to daemon and return response.""" + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + sock.connect(SOCKET_PATH) + sock.sendall(cmd.encode() + b"\n") + response = sock.recv(8192).decode() + sock.close() + return response + + +def is_daemon_running() -> bool: + """Check if daemon is available.""" + try: + send_command("GET") + return True + except (FileNotFoundError, ConnectionRefusedError): + return False + + +def get_states() -> dict[str, RGBState]: + """Get current state for all devices.""" + response = send_command("GET") + states = {} + for line in response.strip().split("\n"): + if line.startswith("SET"): + state = RGBState.from_line(line) + states[state.device] = state + return states + + +def set_rgb(state: RGBState) -> tuple[bool, str]: + """Send RGB command to daemon. Returns (success, message).""" + try: + response = send_command(state.to_command()) + if response.strip().startswith("OK"): + return True, "OK" + else: + return False, response.strip() + except FileNotFoundError: + return False, "Daemon not running (socket not found)" + except ConnectionRefusedError: + return False, "Cannot connect to daemon" + except Exception as e: + return False, str(e)