added applet and updated README

This commit is contained in:
2026-02-06 18:56:26 +02:00
parent e80a77f30b
commit c7e4798771
7 changed files with 352 additions and 4 deletions

View File

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

View File

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

Binary file not shown.

Binary file not shown.

9
acer-rgb-tray.desktop Normal file
View File

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

183
acer-rgb-tray.py Executable file
View File

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

118
daemon_client.py Normal file
View File

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