added applet and updated README
This commit is contained in:
6
Makefile
6
Makefile
@@ -2,10 +2,10 @@
|
|||||||
all: build
|
all: build
|
||||||
|
|
||||||
# rgb:
|
# rgb:
|
||||||
# clang++ -std=c++23 ./rgb.cpp -o ./rgb
|
# clang++-14 -std=c++23 ./rgb.cpp -o ./rgb
|
||||||
|
|
||||||
acer-rgb-cli:
|
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
|
build-cli: acer-rgb-cli
|
||||||
|
|
||||||
@@ -13,7 +13,7 @@ clean-cli:
|
|||||||
rm -f acer-rgb-cli
|
rm -f acer-rgb-cli
|
||||||
|
|
||||||
acer-rgbd:
|
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
|
build: acer-rgbd
|
||||||
|
|
||||||
|
|||||||
38
README.md
38
README.md
@@ -1,5 +1,7 @@
|
|||||||
# acer-lighting
|
# 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**
|
**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.
|
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.
|
||||||
@@ -48,6 +50,41 @@ If you need to undo the install:
|
|||||||
sudo make uninstall
|
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
|
## Usage
|
||||||
|
|
||||||
- Send commands to the daemon using the `acer-rgb` helper (it talks to the daemon socket):
|
- 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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
BIN
__pycache__/acer-rgb-tray.cpython-312.pyc
Normal file
BIN
__pycache__/acer-rgb-tray.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/daemon_client.cpython-312.pyc
Normal file
BIN
__pycache__/daemon_client.cpython-312.pyc
Normal file
Binary file not shown.
9
acer-rgb-tray.desktop
Normal file
9
acer-rgb-tray.desktop
Normal 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
183
acer-rgb-tray.py
Executable 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
118
daemon_client.py
Normal 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)
|
||||||
Reference in New Issue
Block a user