From 357edcac2134769ac21374a1d0d80a932bc2dd70 Mon Sep 17 00:00:00 2001 From: "Sir.Dre" Date: Wed, 17 Jun 2026 22:56:39 -0600 Subject: [PATCH 1/5] Merge - Add support for Alpha ASM100 ACPI/WMI controller - Implemented color utility functions for RGB parsing and validation in colorutil.py. - Created controller_asm100 for the AlienFXControllerASM100 class, specializing in ACPI/WMI control for the Alienware Alpha ASM100. - Added sysfs.py for sysfs access helpers, enabling communication with the alienware-wmi kernel module. - Introduced asm100_red.json theme file for the ASM100 controller, defining color states for various power conditions. - Documented ACPI/WMI controller support in Knowledgebase, detailing requirements and troubleshooting steps. - Updated man page for alienfx to include new features and changes. - Added remove.py script for uninstallation of the AlienFX application and its associated files. --- ASM100_INTEGRATION.md | 112 +++++ README.md | 2 +- alienfx/__init__.py | 15 + alienfx/common.py | 2 +- alienfx/core/acpi_controller.py | 449 ++++++++++++++++++ alienfx/core/colorutil.py | 148 ++++++ alienfx/core/controller_asm100.py | 243 ++++++++++ alienfx/core/prober.py | 39 +- alienfx/core/sysfs.py | 162 +++++++ alienfx/core/themefile.py | 115 ++++- .../data/etc/udev/rules.d/10-alienfx.rules | 13 + alienfx/data/themes/asm100_red.json | 168 +++++++ alienfx/data/themes/default.json | 9 + alienfx/ui/console/main.py | 80 +++- alienfx/ui/gtkui/__init__.py | 8 +- alienfx/ui/gtkui/action_renderer.py | 25 +- alienfx/ui/gtkui/gtkui.py | 111 ++++- .../ACPI-WMI Controller Support.md | 82 ++++ docs/man/alienfx.1 | 4 +- docs/man/alienfx.1.gz | Bin 0 -> 642 bytes remove.py | 311 ++++++++++++ requirements.txt | 2 + setup.py | 32 +- 23 files changed, 2063 insertions(+), 69 deletions(-) create mode 100644 ASM100_INTEGRATION.md create mode 100644 alienfx/core/acpi_controller.py create mode 100644 alienfx/core/colorutil.py create mode 100644 alienfx/core/controller_asm100.py create mode 100644 alienfx/core/sysfs.py create mode 100644 alienfx/data/themes/asm100_red.json create mode 100644 docs/Knowledgebase/ACPI-WMI Controller Support.md create mode 100644 docs/man/alienfx.1.gz create mode 100644 remove.py diff --git a/ASM100_INTEGRATION.md b/ASM100_INTEGRATION.md new file mode 100644 index 0000000..617003a --- /dev/null +++ b/ASM100_INTEGRATION.md @@ -0,0 +1,112 @@ +# ASM100 (Alienware Alpha) GTK UI Integration + +## Summary of Changes + +This document outlines the integration of the Alienware Alpha ASM100 ACPI/WMI controller into the GTK UI system. + +## ASM100 Controller Details + +The ASM100 (Alienware Alpha) is a desktop system with ACPI/WMI-based lighting control: + +- **Device Type**: Desktop (Chassis) +- **Control Interface**: alienware-wmi kernel module (ACPI/WMI) +- **LED Zones**: 2 + - LED 0: Alienhead (Group01) + - LED 1: Side Left (Group02) +- **Power States**: AC-only (no battery states) + - Boot (visualization type 1) + - AC Sleep (visualization type 2) + - AC Charged (visualization type 5) + - AC On (visualization type 8) +- **Actions per Zone**: 1 + +## GTK UI Compatibility + +The GTK UI now properly supports both: +- **Laptop controllers** (with battery states): M-series, R-series, etc. +- **Desktop controllers** (AC-only): ASM100, Aurora, Area51, etc. + +The `load_theme()` method dynamically filters power states based on what the controller supports, making it agnostic to controller type. + +## Installation & Usage + +### Install Dependencies +```bash +pip install -r requirements.txt +``` + +### Launch GTK UI +```bash +alienfx # or python3 -m alienfx.ui.gtkui.gtkui +``` + +The GTK UI will automatically detect your controller (USB or ACPI/WMI) and load appropriate zones and states. + +## Color Utilities API + +The new `colorutil` module provides: + +- `parse_rgb_string(input_str)` - Parse named colors or RGB triplets +- `validate_rgb_values(r, g, b, max_val)` - Validate RGB ranges +- `rgb_to_hex(r, g, b)` - Convert to hex color strings +- `hex_to_rgb(hex_str, scale_to_4bit)` - Convert from hex + +### Examples +```python +from alienfx.core.colorutil import parse_rgb_string + +# Named colors +parse_rgb_string('red') # (15, 0, 0) +parse_rgb_string('cyan') # (0, 15, 15) + +# RGB triplets +parse_rgb_string('15 8 0') # (15, 8, 0) + +# Fallback to blue +parse_rgb_string('invalid') # (0, 0, 15) +``` + +## Files Modified + +1. `alienfx/ui/gtkui/gtkui.py` - Dynamic power state filtering +2. `alienfx/ui/console/main.py` - Use shared colorutil +3. `alienfx/core/colorutil.py` - New shared utility module +4. `requirements.txt` - Added PyGObject and pycairo + +## Testing + +Verify installation: +```bash +python3 -c "from alienfx.core.controller_asm100 import AlienFXControllerASM100; asm100 = AlienFXControllerASM100(); print(asm100.name)" +``` + +Expected output: +``` +Alienware Alpha ASM100 +``` + +## Issues Fixed + +### 1. **Hardcoded Power States in GTK UI** + - **Problem**: The `load_theme()` method in `gtkui.py` was hardcoding all power states including battery states (STATE_BATTERY_SLEEP, STATE_BATTERY_ON, STATE_BATTERY_CRITICAL) + - **Impact**: The ASM100 desktop controller only supports AC states (no battery), causing incompatibility with the GUI + - **Solution**: Modified `load_theme()` to dynamically read available states from the controller's `state_map` + - **File**: `alienfx/ui/gtkui/gtkui.py` + +### 2. **Missing Color Utility Module** + - **Problem**: RGB color parsing code was duplicated in `console/main.py` with inconsistent implementations + - **Impact**: No centralized, tested color parsing available for UI modules + - **Solution**: Created `alienfx/core/colorutil.py` with comprehensive color utilities + - **File**: `alienfx/core/colorutil.py` (new) + +### 3. **Console UI Color Parsing** + - **Problem**: Local `parse_rgb_string()` function in `console/main.py` was redundant + - **Impact**: Code duplication, maintenance burden + - **Solution**: Updated to import from `colorutil` + - **File**: `alienfx/ui/console/main.py` + +### 4. **Missing Dependencies** + - **Problem**: PyGObject and pycairo not listed in requirements + - **Impact**: GTK UI would fail without explicit installation + - **Solution**: Added to `requirements.txt` + - **File**: `requirements.txt` diff --git a/README.md b/README.md index 89c6487..89bac69 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ AlienFX is a Linux utility to control the lighting effects of your Alienware com At present there is a CLI version (``alienfx``) and a gtk GUI version (``alienfx-gtk``). And has been tested on Debian/Ubuntu/Kali/Mint, Fedora and Arch Linux. -[![Version](https://img.shields.io/badge/version-2.4.3-red.svg)]() [![GitHub license](https://img.shields.io/github/license/trackmastersteve/alienfx.svg)](https://github.com/trackmastersteve/alienfx/tree/2.1.x/LICENSE) [![Python3](https://img.shields.io/badge/python-3.12-green.svg)]() [![GitHub issues](https://img.shields.io/github/issues/trackmastersteve/alienfx.svg)](https://github.com/trackmastersteve/alienfx/issues) [![GitHub stars](https://img.shields.io/github/stars/trackmastersteve/alienfx.svg)](https://github.com/trackmastersteve/alienfx/stargazers) [![GitHub forks](https://img.shields.io/github/forks/trackmastersteve/alienfx.svg)](https://github.com/trackmastersteve/alienfx/network) +[![Version](https://img.shields.io/badge/version-2.4.4-red.svg)]() [![GitHub license](https://img.shields.io/github/license/trackmastersteve/alienfx.svg)](https://github.com/trackmastersteve/alienfx/tree/2.1.x/LICENSE) [![Python3](https://img.shields.io/badge/python-3.12-green.svg)]() [![GitHub issues](https://img.shields.io/github/issues/trackmastersteve/alienfx.svg)](https://github.com/trackmastersteve/alienfx/issues) [![GitHub stars](https://img.shields.io/github/stars/trackmastersteve/alienfx.svg)](https://github.com/trackmastersteve/alienfx/stargazers) [![GitHub forks](https://img.shields.io/github/forks/trackmastersteve/alienfx.svg)](https://github.com/trackmastersteve/alienfx/network) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](http://makeapullrequest.com) Contributers needed! Please read [CONTRIBUTING.md](https://github.com/trackmastersteve/alienfx/blob/master/CONTRIBUTING.md) for further details. diff --git a/alienfx/__init__.py b/alienfx/__init__.py index e69de29..c507bcb 100644 --- a/alienfx/__init__.py +++ b/alienfx/__init__.py @@ -0,0 +1,15 @@ +"""Top-level package exports for alienfx. + +Expose a small helper `Alienware` class for direct sysfs access (HDMI/RGB). +""" +from .core.sysfs import Alienware, HDMI, RGBZones, RGBZone, HDMISource, HDMICableState, Zone + +__all__ = [ + "Alienware", + "HDMI", + "RGBZones", + "RGBZone", + "HDMISource", + "HDMICableState", + "Zone", +] diff --git a/alienfx/common.py b/alienfx/common.py index b704db1..4cbda4f 100644 --- a/alienfx/common.py +++ b/alienfx/common.py @@ -34,4 +34,4 @@ def get_version(): for r in requirements: if r.key == "alienfx": return r.version - return "2.4.3" + return "2.4.4" diff --git a/alienfx/core/acpi_controller.py b/alienfx/core/acpi_controller.py new file mode 100644 index 0000000..1983696 --- /dev/null +++ b/alienfx/core/acpi_controller.py @@ -0,0 +1,449 @@ +# +# acpi_controller.py +# +# Copyright (C) 2013-2014 Ashwin Menon +# Copyright (C) 2015-2024 Track Master Steve +# +# Alienfx is free software. +# +# You may redistribute it and/or modify it under the terms of the +# GNU General Public License, as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) +# any later version. +# +# Alienfx is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with alienfx. If not, write to: +# The Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor +# Boston, MA 02110-1301, USA. +# + +""" Base class for ACPI/WMI-based AlienFX controllers. + +This module provides the following classes: +AlienFXACPIController: base class for ACPI/WMI AlienFX controller chips +AlienFXACPIDriver: low level ACPI/WMI communication driver +""" + +from builtins import hex +from builtins import object +import logging +import os +import struct + +import alienfx. core.cmdpacket as alienfx_cmdpacket +from alienfx.core.themefile import AlienFXThemeFile +from functools import reduce + + +class AlienFXACPIDriver(object): + + """ Provides low level acquire/release and read/write access to an AlienFX + ACPI/WMI controller via sysfs. + """ + + # WMI interface attributes + WMI_COMMAND_ATTR = "lighting_control_state" + WMI_ZONE_ATTR = "lighting_control_zone" + + def __init__(self, controller): + self._control_taken = False + self._controller = controller + self._acpi_path = None + + def write_packet(self, pkt): + """ Write the given packet to the ACPI/WMI interface.""" + if not self._control_taken: + logging.warning("write_packet: control not taken, skipping write") + return + + if not self._acpi_path or not os.path.exists(self._acpi_path): + logging.error("write_packet: ACPI path not available: {}".format(self._acpi_path)) + return + + try: + # Convert packet to bytes if needed + if isinstance(pkt, list): + pkt_bytes = bytes(pkt) + else: + pkt_bytes = pkt + + # Write to the WMI interface + # The alienware-wmi driver expects specific formats + # For now, we'll write the raw packet data + control_file = os.path.join(self._acpi_path, "rgb_zones", self.WMI_COMMAND_ATTR) + + if os.path.exists(control_file): + with open(control_file, 'wb') as f: + f.write(pkt_bytes) + logging.debug("ACPI write successful: {} bytes".format(len(pkt_bytes))) + else: + logging.error("Control file not found: {}".format(control_file)) + + except IOError as exc: + logging.error("write_packet ACPI error: {}".format(exc)) + except Exception as exc: + logging.error("write_packet unexpected error: {}".format(exc)) + + def read_packet(self): + """ Read a packet from the ACPI/WMI interface and return it.""" + if not self._control_taken: + logging.error("read_packet: control not taken...") + return None + + if not self._acpi_path or not os.path.exists(self._acpi_path): + logging.error("read_packet: ACPI path not available: {}".format(self._acpi_path)) + return None + + try: + control_file = os.path.join(self._acpi_path, "rgb_zones", self.WMI_COMMAND_ATTR) + + if os.path.exists(control_file): + with open(control_file, 'rb') as f: + pkt = f.read(self._controller.cmd_packet. PACKET_LENGTH) + if pkt: + logging.debug("ACPI read successful: {} bytes".format(len(pkt))) + return list(pkt) if isinstance(pkt, bytes) else pkt + else: + logging.error("Control file not found: {}".format(control_file)) + + except IOError as exc: + logging.error("read_packet ACPI error: {}".format(exc)) + except Exception as exc: + logging.error("read_packet unexpected error: {}". format(exc)) + + return None + + def acquire(self): + """ Acquire control of the ACPI/WMI AlienFX controller.""" + if self._control_taken: + logging.debug("ACPI device already acquired") + return + + # Get ACPI path from controller + if hasattr(self._controller, 'acpi_path'): + self._acpi_path = self._controller.acpi_path + else: + self._acpi_path = "/sys/devices/platform/alienware-wmi" + + # Check if the ACPI/WMI interface exists + if not os.path. exists(self._acpi_path): + msg = "ERROR: No AlienFX ACPI/WMI controller found at: {}".format(self._acpi_path) + msg += "\nMake sure the alienware-wmi kernel module is loaded." + msg += "\nRun: sudo modprobe alienware-wmi" + logging.error(msg) + raise IOError(msg) + + # Check for required sysfs attributes + control_file = os.path.join(self._acpi_path, self.WMI_COMMAND_ATTR) + if not os.path.exists(control_file): + logging.warning("Control attribute not found: {}".format(control_file)) + logging.warning("Continuing anyway, some features may not work") + + self._control_taken = True + logging.info("ACPI/WMI device acquired: {}".format(self._acpi_path)) + + def release(self): + """ Release control of the ACPI/WMI AlienFX controller.""" + if not self._control_taken: + return + + # No special cleanup needed for ACPI/WMI interface + self._control_taken = False + logging.debug("ACPI/WMI device released: {}".format(self._acpi_path)) + + +class AlienFXACPIController(object): + + """ Provides facilities to communicate with an ACPI/WMI-based AlienFX controller. + + This class provides methods to send commands to an ACPI/WMI AlienFX controller, + and receive status from the controller. It must be overridden to provide + behaviour specific to a particular AlienFX controller. + """ + + # List of all ACPI-based subclasses. Subclasses must add instances of + # themselves to this list. + supported_controllers = [] + + # Zone names (same as USB controllers) + ZONE_LEFT_KEYBOARD = "Left Keyboard" + ZONE_MIDDLE_LEFT_KEYBOARD = "Middle-left Keyboard" + ZONE_MIDDLE_RIGHT_KEYBOARD = "Middle-right Keyboard" + ZONE_RIGHT_KEYBOARD = "Right Keyboard" + ZONE_RIGHT_SPEAKER = "Right Speaker" + ZONE_LEFT_SPEAKER = "Left Speaker" + ZONE_ALIEN_HEAD = "Alien Head" + ZONE_LOGO = "Logo" + ZONE_TOUCH_PAD = "Touchpad" + ZONE_MEDIA_BAR = "Media Bar" + ZONE_STATUS_LEDS = "Status LEDs" + ZONE_POWER_BUTTON = "Power Button" + ZONE_HDD_LEDS = "HDD LEDs" + ZONE_RIGHT_DISPLAY = "Right Display" + ZONE_LEFT_DISPLAY = "Left Display" + + # State names + STATE_BOOT = "Boot" + STATE_AC_SLEEP = "AC Sleep" + STATE_AC_CHARGED = "AC Charged" + STATE_AC_CHARGING = "AC Charging" + STATE_BATTERY_SLEEP = "Battery Sleep" + STATE_BATTERY_ON = "Battery On" + STATE_BATTERY_CRITICAL = "Battery Critical" + STATE_AC_ON = "AC On" + + ALIENFX_CONTROLLER_TYPE = "acpi" + + def __init__(self, conrev=1): + # conrev=1 -> old controllers (DEFAULT) + # conrev=2 -> newer controllers (17R4 ...) + self.zone_map = {} + self.power_zones = [] + self.reset_types = {} + self.state_map = {} + self.vendor_id = 0 # Not used for ACPI, kept for compatibility + self.product_id = 0 # Not used for ACPI, kept for compatibility + self.acpi_path = "/sys/devices/platform/alienware-wmi" + + self.cmd_packet = alienfx_cmdpacket.AlienFXCmdPacket(conrev) + self._driver = AlienFXACPIDriver(self) + + def get_zone_name(self, pkt): + """ Given 3 bytes of a command packet, return a string zone + name corresponding to it + """ + zone_mask = (pkt[0] << 16) + (pkt[1] << 8) + pkt[2] + zone_name = "" + for zone in self.zone_map: + bit_mask = self.zone_map[zone] + if zone_mask & bit_mask: + if zone_name != "": + zone_name += "," + zone_name += zone + zone_mask &= ~bit_mask + if zone_mask != 0: + if zone_name != "": + zone_name += "," + zone_name += "UNKNOWN({})".format(hex(zone_mask)) + return zone_name + + def get_state_name(self, state): + """ Given a state number, return a string state name """ + for state_name in self.state_map: + if self.state_map[state_name] == state: + return state_name + return "UNKNOWN" + + def get_reset_type_name(self, num): + """ Given a reset number, return a string reset name """ + if num in list(self.reset_types.keys()): + return self.reset_types[num] + else: + return "UNKNOWN" + + def _ping(self): + """ Send a get-status command to the controller.""" + controller_type = getattr(self, "_controller_type", self.ALIENFX_CONTROLLER_TYPE) + if controller_type != self.ALIENFX_CONTROLLER_TYPE: + logging.debug("WMI status ping skipped") + return + pkt = self.cmd_packet. make_cmd_get_status() + logging.debug("SENDING: {}".format(self.pkt_to_string(pkt))) + self._driver.write_packet(pkt) + # ACPI might not support reading status the same way + # Try to read, but don't fail if it doesn't work + try: + self._driver.read_packet() + except Exception as e: + logging.debug("Status read not supported on ACPI: {}".format(e)) + + def _reset(self, reset_type): + """ Send a "reset" packet to the AlienFX controller.""" + reset_code = self._get_reset_code(reset_type) + pkt = self. cmd_packet.make_cmd_reset(reset_code) + logging.debug("SENDING: {}".format(self.pkt_to_string(pkt))) + self._driver.write_packet(pkt) + + def _wait_controller_ready(self): + """ Keep sending a "get status" packet to the AlienFX controller and + return only when the controller is ready. + + ACPI controllers can be polled, status checking may not be available. + WMI controllers do not expose the same status path, so readiness is treated as immediate. + ACPI-style effects like morphing are not replayed through the WMI RGB interface. + """ + controller_type = getattr(self, "_controller_type", self.ALIENFX_CONTROLLER_TYPE) + if controller_type != self.ALIENFX_CONTROLLER_TYPE: + logging.debug("WMI controller ready check skipped") + return + + ready = False + errcount = 0 + max_retries = 10 # Fewer retries for ACPI + + while not ready and errcount < max_retries: + pkt = self.cmd_packet.make_cmd_get_status() + logging.debug("SENDING: {}".format(self.pkt_to_string(pkt))) + self._driver.write_packet(pkt) + try: + resp = self._driver.read_packet() + if resp: + ready = (resp[0] == self.cmd_packet.STATUS_READY) + else: + # If we can't read status, assume ready after a few tries + errcount += 1 + if errcount >= 3: + logging.debug("ACPI status not available, assuming ready") + ready = True + except TypeError: + errcount += 1 + logging.debug("No Status received yet... Failed tries={}".format(errcount)) + except Exception as e: + logging.debug("Status check error: {}".format(e)) + errcount += 1 + + if not ready: + logging.warning("Controller status could not be verified (ACPI mode)") + + def pkt_to_string(self, pkt_bytes): + """ Return a human readable string representation of an AlienFX + command packet. + """ + return self.cmd_packet.pkt_to_string(pkt_bytes, self) + + def _get_no_zone_code(self): + """ Return a zone code corresponding to all non-visible zones.""" + zone_codes = [self.zone_map[x] for x in self.zone_map] + return ~reduce(lambda x,y: x|y, zone_codes, 0) + + def _get_zone_codes(self, zone_names): + """ Given zone names, return the zone codes they refer to. + """ + zones = 0 + for zone in zone_names: + if zone in self.zone_map: + zones |= self.zone_map[zone] + return zones + + def _get_reset_code(self, reset_name): + """ Given the name of a reset action, return its code. """ + for reset in self.reset_types: + if reset_name == self.reset_types[reset]: + return reset + logging.warning("Unknown reset type: {}".format(reset_name)) + return 0 + + def _make_loop_cmds(self, themefile, zones, block, loop_items): + """ Given loop-items from the theme file, return a list of loop + commands. + """ + loop_cmds = [] + pkt = self.cmd_packet + for item in loop_items: + item_type = themefile.get_action_type(item) + item_colours = themefile.get_action_colours(item) + if item_type == AlienFXThemeFile.KW_ACTION_TYPE_FIXED: + if len(item_colours) != 1: + logging.warning("fixed must have exactly one colour value") + continue + loop_cmds.append( + pkt.make_cmd_set_colour(block, zones, item_colours[0])) + elif item_type == AlienFXThemeFile.KW_ACTION_TYPE_BLINK: + if len(item_colours) != 1: + logging.warning("blink must have exactly one colour value") + continue + loop_cmds.append( + pkt.make_cmd_set_blink_colour(block, zones, item_colours[0])) + elif item_type == AlienFXThemeFile.KW_ACTION_TYPE_MORPH: + if len(item_colours) != 2: + logging.warning("morph must have exactly two colour values") + continue + loop_cmds.append( + pkt.make_cmd_set_morph_colour( + block, zones, item_colours[0], item_colours[1])) + else: + logging.warning("unknown loop item type: {}".format(item_type)) + return loop_cmds + + def _make_zone_cmds(self, themefile, state_name, boot=False): + """ Given a theme file, return a list of zone commands. + + If 'boot' is True, then the colour commands created are not saved with + SAVE_NEXT commands. Also, the final command is one to set the colour + of all non-visible zones to black. + """ + zone_cmds = [] + block = 1 + pkt = self.cmd_packet + state = self.state_map[state_name] + state_items = themefile.get_state_items(state_name) + for item in state_items: + zone_codes = self._get_zone_codes(themefile.get_zone_names(item)) + loop_items = themefile.get_loop_items(item) + loop_cmds = self._make_loop_cmds( + themefile, zone_codes, block, loop_items) + if (loop_cmds): + block += 1 + for loop_cmd in loop_cmds: + if not boot: + zone_cmds.append(pkt.make_cmd_save_next(state)) + zone_cmds. append(loop_cmd) + if not boot: + zone_cmds.append(pkt. make_cmd_save_next(state)) + zone_cmds.append(pkt.make_cmd_loop_block_end()) + if zone_cmds: + if not boot: + zone_cmds.append(pkt.make_cmd_save()) + if boot: + zone_cmds. append( + pkt.make_cmd_set_colour( + block, self._get_no_zone_code(), (0,0,0))) + zone_cmds.append(pkt.make_cmd_loop_block_end()) + return zone_cmds + + def _send_cmds(self, cmds): + """ Send the given commands to the controller. """ + for cmd in cmds: + logging.debug("SENDING: {}". format(self.pkt_to_string(cmd))) + self._driver.write_packet(cmd) + + def set_theme(self, themefile): + """ Send the given theme settings to the controller. This should result + in the lights changing to the theme settings immediately. + """ + try: + self._driver.acquire() + cmds_boot = [] + pkt = self.cmd_packet + + # prepare the controller + self._ping() + self._reset("all-lights-on") + self._wait_controller_ready() + + for state_name in self.state_map: + cmds = [] + cmds = self._make_zone_cmds(themefile, state_name) + # Boot block commands are saved for sending again later. + # The second time, they are sent without SAVE_NEXT commands. + if (state_name == self.STATE_BOOT): + cmds_boot = self._make_zone_cmds( + themefile, state_name, boot=True) + self._send_cmds(cmds) + cmd = pkt.make_cmd_set_speed(themefile.get_speed()) + self._send_cmds([cmd]) + # send the boot block commands again + self._send_cmds(cmds_boot) + cmd = pkt.make_cmd_transmit_execute() + self._send_cmds([cmd]) + except Exception as e: + logging. error("Error setting theme on ACPI controller: {}".format(e)) + raise + finally: + self._driver.release() diff --git a/alienfx/core/colorutil.py b/alienfx/core/colorutil.py new file mode 100644 index 0000000..9bacb70 --- /dev/null +++ b/alienfx/core/colorutil.py @@ -0,0 +1,148 @@ +# +# colorutil.py +# +# Copyright (C) 2015-2024 Track Master Steve +# +# Alienfx is free software. +# +# You may redistribute it and/or modify it under the terms of the +# GNU General Public License, as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) +# any later version. +# +# Alienfx is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with alienfx. If not, write to: +# The Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor +# Boston, MA 02110-1301, USA. +# + +""" Color utility functions for RGB parsing and validation. + +This module provides common color utilities used by both console and GUI interfaces. +""" + + +def parse_rgb_string(input_str): + """Parse an RGB color string into a tuple of (red, green, blue) values. + + Supports: + - Named colors: 'black', 'white', 'red', 'yellow', 'green', 'cyan', 'blue', 'magenta' + - RGB triplets: '255 128 0' or '15 8 0' (depending on system) + - Default fallback: (0, 0, 15) - blue + + Args: + input_str (str): Color string to parse (e.g., 'red', '15 0 0') + + Returns: + tuple: (r, g, b) values as integers, or None if invalid + + Examples: + >>> parse_rgb_string('red') + (15, 0, 0) + >>> parse_rgb_string('15 8 0') + (15, 8, 0) + >>> parse_rgb_string('invalid') + (0, 0, 15) + """ + if input_str is None: + return None + + s = input_str.lower().strip() + + # Named colors mapping (4-bit color values: 0-15) + named_colors = { + 'black': (0, 0, 0), + 'white': (15, 15, 15), + 'red': (15, 0, 0), + 'yellow': (15, 15, 0), + 'green': (0, 15, 0), + 'cyan': (0, 15, 15), + 'blue': (0, 0, 15), + 'magenta': (15, 0, 15), + } + + if s in named_colors: + return named_colors[s] + + # Try parsing as RGB triplet + try: + parts = [int(x) for x in s.split()] + if len(parts) == 3: + # Validate range (support both 0-15 and 0-255) + for val in parts: + if val < 0 or val > 255: + break + else: + return (parts[0], parts[1], parts[2]) + except (ValueError, AttributeError): + pass + + # Default fallback to blue + return (0, 0, 15) + + +def validate_rgb_values(r, g, b, max_val=15): + """Validate RGB values are within acceptable range. + + Args: + r, g, b (int): Red, green, blue values + max_val (int): Maximum allowed value (default 15 for 4-bit, 255 for 8-bit) + + Returns: + bool: True if all values are valid (0 <= value <= max_val) + """ + return all(0 <= v <= max_val for v in [r, g, b]) + + +def rgb_to_hex(r, g, b): + """Convert RGB values to hex color string. + + Args: + r, g, b (int): Red, green, blue values (0-15) + + Returns: + str: Hex color string (e.g., '#FF0000') + """ + # Scale 4-bit values (0-15) to 8-bit (0-255) + r_scaled = int((r / 15.0) * 255) if r <= 15 else r + g_scaled = int((g / 15.0) * 255) if g <= 15 else g + b_scaled = int((b / 15.0) * 255) if b <= 15 else b + + return "#{:02X}{:02X}{:02X}".format(r_scaled, g_scaled, b_scaled) + + +def hex_to_rgb(hex_str, scale_to_4bit=True): + """Convert hex color string to RGB values. + + Args: + hex_str (str): Hex color string (e.g., '#FF0000') + scale_to_4bit (bool): If True, scale to 4-bit (0-15), else 8-bit (0-255) + + Returns: + tuple: (r, g, b) values + """ + hex_str = hex_str.lstrip('#') + + if len(hex_str) != 6: + return (0, 0, 15) # Default blue + + try: + r = int(hex_str[0:2], 16) + g = int(hex_str[2:4], 16) + b = int(hex_str[4:6], 16) + + if scale_to_4bit: + # Scale 8-bit values (0-255) to 4-bit (0-15) + r = int((r / 255.0) * 15) + g = int((g / 255.0) * 15) + b = int((b / 255.0) * 15) + + return (r, g, b) + except ValueError: + return (0, 0, 15) # Default blue diff --git a/alienfx/core/controller_asm100.py b/alienfx/core/controller_asm100.py new file mode 100644 index 0000000..1b85950 --- /dev/null +++ b/alienfx/core/controller_asm100.py @@ -0,0 +1,243 @@ +# +# controller_asm100.py +# +# Alienfx is free software. +# +# You may redistribute it and/or modify it under the terms of the +# GNU General Public License, as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) +# any later version. +# +# Alienfx is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +""" Specialization of the AlienFxController class for the Alienware Alpha ASM100 controller. + +This module provides the following classes: +AlienFXControllerASM100 : Alienware Alpha ASM100 ACPI-based controller +""" + +import logging + +import alienfx.core.acpi_controller as acpi_controller +from alienfx.core.colorutil import validate_rgb_values +from alienfx.core.sysfs import Alienware, Zone + +class AlienFXControllerASM100(acpi_controller.AlienFXACPIController): + + """ Specialization of the AlienFXACPIController class for the Alpha ASM100. + + The Alienware Alpha ASM100 uses ACPI/WMI for lighting control rather than USB. + This is a compact desktop form factor (Chassis deviceTypeID: 1) with two lighting zones. + + Device Information from AWCC: + - VID: 187C + - PID: ASM100 + - Device Type: Chassis (deviceTypeID: 1) + - LED Zones: 2 (Alien Head - LED 0, Side Left - LED 1) + - Actions per Zone: 1 + """ + + # Speed capabilities (tempo from theme) + DEFAULT_SPEED = 200 + MIN_SPEED = 50 + + # Zone codes for Alienware Alpha ASM100 + # Based on the JSON theme structure: + # LED 0 - Alien Head (Group01) + # LED 1 - Side Left (Group02) + ALIEN_HEAD = 0x0001 # ledID: 0 + SIDE_LEFT = 0x0002 # ledID: 1 + + # Legacy compatibility (POWER_BUTTON may not exist as separate zone) + POWER_BUTTON = ALIEN_HEAD # Map to Alienhead for backward compatibility + + # Reset codes + RESET_ALL_LIGHTS_OFF = 3 + RESET_ALL_LIGHTS_ON = 4 + + # State codes - Alpha is a desktop so battery states are not applicable + # These correspond to visualization types in the JSON + BOOT = 1 # Default visualization (visualizationTypeID: 1) + AC_SLEEP = 2 # Sleep Mode visualization (visualizationTypeID: 2) + AC_CHARGED = 5 # Not applicable for desktop + AC_ON = 8 # Active state + + # Action types from JSON theme + ACTION_MORPH = 1 + ACTION_PULSE = 2 + ACTION_SET_COLOR = 3 + ACTION_LOOP = 4 + ACTION_PLAY_POWER_STATUS = 15 + + # Device identification + DEVICE_VID = "187C" + DEVICE_PID = "ASM100" + DEVICE_TYPE_CHASSIS = 1 + CONTROLLER_TYPE_WMI = "wmi" + + def __init__(self): + acpi_controller.AlienFXACPIController.__init__(self) + self.name = "Alienware Alpha ASM100" + self._controller_type = self.CONTROLLER_TYPE_WMI + + self.vendor_id = self.DEVICE_VID + self.product_id = self.DEVICE_PID + + + # ACPI/WMI identification + # The Alpha uses the alienware-wmi kernel module + self.acpi_path = "/sys/devices/platform/alienware-wmi" + self.device_vid = self.DEVICE_VID + self.device_pid = self.DEVICE_PID + self.device_type = self.DEVICE_TYPE_CHASSIS + + # Map zone names to their LED IDs (based on JSON leds array) + self.zone_map = { + self.ZONE_ALIEN_HEAD: self.ALIEN_HEAD, # LED 0 - Alien Head + self.ZONE_POWER_BUTTON: self.ALIEN_HEAD, # LED 0 - Mapped to Alien Head + "Side Left": self.SIDE_LEFT, # LED 1 - Side Left + } + + # LED groups mapping (from JSON groups and zonesGroups) + self.led_groups = { + 1: "Alien Head", # Group01 - groupID: 1 + 2: "Side Left", # Group02 - groupID: 2 + } + + # LED definitions (from JSON leds array) + self.leds = { + 0: { + "name": "Led 0", + "description": "Alien Head", + "groupID": 1 + }, + 1: { + "name": "Led 1", + "description": "Side Left", + "groupID": 2 + } + } + + # Zones that have special behaviour in different power states + # Based on visualization types + self.power_zones = [ + self.ZONE_ALIEN_HEAD, + "Side Left", + ] + + # Map reset names to their codes + self.reset_types = { + self.RESET_ALL_LIGHTS_OFF: "all-lights-off", + self.RESET_ALL_LIGHTS_ON: "all-lights-on" + } + + # Map state names to their codes + # Desktop only has AC power states + # Corresponds to visualizationTypes in JSON + self.state_map = { + self.STATE_BOOT: self.BOOT, # Default (visualizationTypeID: 1) + self.STATE_AC_SLEEP: self.AC_SLEEP, # Sleep Mode (visualizationTypeID: 2) + self.STATE_AC_CHARGED: self.AC_CHARGED, + self.STATE_AC_ON: self.AC_ON, + } + + # Action types mapping (from JSON actionTypes) + self.action_types = { + self.ACTION_MORPH: "Morph", + self.ACTION_PULSE: "Pulse", + self.ACTION_SET_COLOR: "SetColor", + self.ACTION_LOOP: "Loop", + self.ACTION_PLAY_POWER_STATUS: "PlayPowerStatus" + } + + # Theme configuration defaults (from JSON theme) + self.theme_config = { + "mode": 0, + "actionDuration": 3, + "tempo": 200, + "actionsPerZone": 1 + } + + def _zone_name_to_sysfs_zone(self, zone_name): + if zone_name in (self.ZONE_ALIEN_HEAD, self.ZONE_POWER_BUTTON): + return Zone.Head + if zone_name == "Side Left": + return Zone.Left + return None + + def _first_action_colour(self, themefile, item): + for action in themefile.get_loop_items(item): + colours = themefile.get_action_colours(action) + if colours: + return colours[0] + return None + + def set_theme(self, themefile): + """Apply the theme through the alienware-wmi RGB sysfs interface.""" + aw = Alienware(self.acpi_path) + if not aw.is_alienware(): + raise IOError("ASM100 RGB sysfs path not found: {}".format(self.acpi_path)) + + zone_colours = {} + for item in themefile.get_state_items(self.STATE_BOOT): + colour = self._first_action_colour(themefile, item) + if colour is None: + continue + for zone_name in themefile.get_zone_names(item): + zone = self._zone_name_to_sysfs_zone(zone_name) + if zone is not None: + zone_colours[zone] = colour + + if not zone_colours: + logging.warning("ASM100 theme has no Boot-state colours to apply") + return + + for zone, colour in zone_colours.items(): + red, green, blue = colour + if not validate_rgb_values(red, green, blue, max_val=15): + logging.warning("Skipping invalid ASM100 colour for zone %s: %s", zone, colour) + continue + aw.set_rgb_zone(zone, red, green, blue) + + def get_led_by_id(self, led_id): + """Get LED information by LED ID. + + Args: + led_id: The LED identifier (0 for Alienhead, 1 for Side Left) + + Returns: + Dictionary containing LED information or None if not found + """ + return self.leds.get(led_id) + + def get_group_name(self, group_id): + """Get group name by group ID. + + Args: + group_id: The group identifier (1 or 2) + + Returns: + String containing group name or None if not found + """ + return self.led_groups.get(group_id) + + def get_zone_by_group(self, group_id): + """Get zone code by group ID. + + Args: + group_id: The group identifier (1 for Alienhead, 2 for Side Left) + + Returns: + Zone code corresponding to the group + """ + if group_id == 1: + return self.ALIEN_HEAD + elif group_id == 2: + return self.SIDE_LEFT + return None + +acpi_controller.AlienFXACPIController.supported_controllers.append( + AlienFXControllerASM100()) \ No newline at end of file diff --git a/alienfx/core/prober.py b/alienfx/core/prober.py index a5f24ac..24f91ef 100644 --- a/alienfx/core/prober.py +++ b/alienfx/core/prober.py @@ -1,10 +1,10 @@ # # prober.py # -# Copyright (C) 2013-2014 Ashwin Menon +# Copyright (C) 2013-2014 Ashwin Menon # Copyright (C) 2015-2024 Track Master Steve # -# Alienfx is free software. +# Alienfx is free software. # # You may redistribute it and/or modify it under the terms of the # GNU General Public License, as published by the Free Software @@ -13,33 +13,35 @@ # # Alienfx is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. # See the GNU General Public License for more details. # # You should have received a copy of the GNU General Public License -# along with alienfx. If not, write to: +# along with alienfx. If not, write to: # The Free Software Foundation, Inc., # 51 Franklin Street, Fifth Floor # Boston, MA 02110-1301, USA. # -""" Alien FX controller prober class. +""" Alien FX controller prober class. This module provides the following classes: -AlienFXProber: probes the USB bus for supported Alien FX controllers. +AlienFXProber: probes the USB bus for supported Alien FX controllers. """ - +from alienfx.core.acpi_controller import AlienFXACPIController from builtins import object from builtins import int +import os import usb import usb.core from alienfx.core.controller import AlienFXController as AlienFXController -""" Import all subclasses of AlienFXController here. """ +""" Import all subclasses of AlienFXController here. """ import alienfx.core.controller_a51m import alienfx.core.controller_area51 import alienfx.core.controller_area51_r2 +import alienfx.core.controller_asm100 import alienfx.core.controller_aurora import alienfx.core.controller_m11xr1 import alienfx.core.controller_m11xr2 @@ -66,16 +68,21 @@ class AlienFXProber(object): @staticmethod def get_controller(): - """ Go through the supported_controllers list in AlienFXController - and see if any of them exist on the USB bus, Return the first one - found, or None if none are found. - """ - for controller in AlienFXController.supported_controllers: - vid = controller.vendor_id + # First check USB controllers + for controller in AlienFXController. supported_controllers: + vid = controller. vendor_id pid = controller.product_id dev = usb.core.find(idVendor=vid, idProduct=pid) if dev is not None: return controller + + # Then check ACPI controllers + for controller in AlienFXACPIController. supported_controllers: + # Check if ACPI path exists + acpi_path = getattr(controller, 'acpi_path', '/sys/devices/platform/alienware-wmi') + if os.path.exists(acpi_path): + return controller + return None @staticmethod @@ -85,8 +92,8 @@ def find_controllers(vendor): devs = usb.core.find(find_all=1) # All USB devices devices = [] # List of found AFX-Controllers - for dev in devs: - if dev is not None: + for dev in devs: + if dev is not None: if dev.idVendor is not None: if dev.idVendor == vid: devices.append(dev) diff --git a/alienfx/core/sysfs.py b/alienfx/core/sysfs.py new file mode 100644 index 0000000..021aa0c --- /dev/null +++ b/alienfx/core/sysfs.py @@ -0,0 +1,162 @@ +"""Sysfs access helpers for AlienFX (HDMI and RGB zones). + +This module provides a small API to read/write the same sysfs files +that the rust `alienware-wmi` library uses. It mirrors the behaviour +needed by the CLI in the attachments. +""" +from dataclasses import dataclass +from enum import Enum +import os +import re +from pathlib import Path +from typing import Dict, Optional, Tuple + + +class HDMISource(Enum): + Cable = "cable" + Gpu = "gpu" + Unknown = "unknown" + + def __str__(self) -> str: + return self.value + + +class HDMICableState(Enum): + Connected = "connected" + Unconnected = "unconnected" + Unknown = "unknown" + + def __str__(self) -> str: + return self.value + + +class Zone(Enum): + Head = 0 + Left = 1 + Right = 2 + + def __str__(self) -> str: + if self == Zone.Head: + return "head" + if self == Zone.Left: + return "left" + return "right" + + +@dataclass +class HDMI: + source: HDMISource + cable_state: HDMICableState + exists: bool = False + + +@dataclass +class RGBZone: + zone: Zone + red: int + green: int + blue: int + + +@dataclass +class RGBZones: + zones: Dict[Zone, RGBZone] + exists: bool = False + + +class Alienware: + """Simple accessor for the alienware-wmi sysfs files. + + Default platform path is `/sys/devices/platform/alienware-wmi`, but a + different path may be provided for testing. + """ + + def __init__(self, platform: Optional[str] = None): + self.platform = platform or "/sys/devices/platform/alienware-wmi" + + def is_alienware(self) -> bool: + return Path(self.platform).exists() + + def _sys_path(self, *parts: str) -> Path: + p = Path(self.platform) + for part in parts: + p = p / part + return p + + def parse_sys_file(self, file_name: str) -> Optional[str]: + """Parse a sysfs single-value file that uses a '[value]' marker. + + Returns the captured value or None if missing. + """ + re_bracket = re.compile(r"\[([^)]+)\]") + path = self._sys_path(file_name) + with open(path, "r", encoding="utf-8") as fh: + contents = fh.read() + m = re_bracket.search(contents) + return m.group(1) if m else None + + def parse_sys_rgb_file(self, file_name: str) -> Tuple[int, int, int]: + """Parse an rgb file containing 'red: X, green: Y, blue: Z'.""" + re_rgb = re.compile(r"red: (\d+), green: (\d+), blue: (\d+)") + path = self._sys_path(file_name) + with open(path, "r", encoding="utf-8") as fh: + contents = fh.read() + m = re_rgb.search(contents) + if m and len(m.groups()) == 3: + return int(m.group(1)), int(m.group(2)), int(m.group(3)) + return 0, 0, 0 + + def get_hdmi(self) -> HDMI: + source = HDMISource.Unknown + cable_state = HDMICableState.Unknown + exists = False + if self.is_alienware(): + exists = True + hdmi_dir = self._sys_path("hdmi") + if hdmi_dir.exists(): + src = self.parse_sys_file("hdmi/source") + if src == "cable": + source = HDMISource.Cable + elif src == "gpu": + source = HDMISource.Gpu + else: + source = HDMISource.Unknown + + cable = self.parse_sys_file("hdmi/cable") + if cable == "connected": + cable_state = HDMICableState.Connected + elif cable == "unconnected": + cable_state = HDMICableState.Unconnected + else: + cable_state = HDMICableState.Unknown + + return HDMI(source=source, cable_state=cable_state, exists=exists) + + def set_hdmi_source(self, source: HDMISource) -> None: + path = self._sys_path("hdmi/source") + with open(path, "w", encoding="utf-8") as fh: + fh.write(str(source)) + + def get_rgb_zones(self) -> RGBZones: + zones: Dict[Zone, RGBZone] = {} + exists = False + if self.is_alienware(): + exists = True + rgb_root = self._sys_path("rgb_zones") + if rgb_root.exists(): + # zone00 -> head, zone01 -> left, zone02 -> right + mapping = {"zone00": Zone.Head, "zone01": Zone.Left, "zone02": Zone.Right} + for fname, z in mapping.items(): + fpath = rgb_root / fname + if fpath.exists(): + r, g, b = self.parse_sys_rgb_file(str(Path("rgb_zones") / fname)) + zones[z] = RGBZone(zone=z, red=r, green=g, blue=b) + return RGBZones(zones=zones, exists=exists) + + def set_rgb_zone(self, zone: Zone, red: int, green: int, blue: int) -> None: + idx = {Zone.Head: "zone00", Zone.Left: "zone01", Zone.Right: "zone02"}[zone] + path = self._sys_path("rgb_zones", idx) + # write as hex pair each (two hex digits per component) like the rust code + value = f"{red:02x}{green:02x}{blue:02x}" + with open(path, "w", encoding="utf-8") as fh: + fh.write(value) diff --git a/alienfx/core/themefile.py b/alienfx/core/themefile.py index 8b8b940..ba7d695 100644 --- a/alienfx/core/themefile.py +++ b/alienfx/core/themefile.py @@ -72,15 +72,16 @@ class AlienFXThemeFile(object): def __init__(self, controller): try: - if not "XDG_CONFIG_HOME" in os.environ: + if "XDG_CONFIG_HOME" not in os.environ: self._theme_dir = os.path.expanduser("~/.config/alienfx") else: self._theme_dir = os.path.join( - os.environ("XDG_CONFIG_HOME"), "alienfx") + os.environ["XDG_CONFIG_HOME"], "alienfx") if not os.path.exists(self._theme_dir): os.makedirs(self._theme_dir) except Exception as exc: logging.error(exc) + self._theme_dir = os.path.expanduser("~/.config/alienfx") self.theme = {} self.theme_name = "" self.controller = controller @@ -208,7 +209,15 @@ def _load_from_file(self, theme_file_path): """ Load a theme from a file.""" try: with open(theme_file_path) as tfile: - self.theme = json.load(tfile) + data = json.load(tfile) + # Detect full device/theme JSON (exported from AWCC) and convert + # into the simpler internal format expected by this app. + if (isinstance(data, dict) and + ("devices" in data or "theme" in data) and + "actions" in data): + self.theme = self._convert_device_theme(data) + else: + self.theme = data theme_name = os.path.splitext( os.path.basename(theme_file_path))[0] if theme_name != os.path.splitext(self.LAST_THEME_FILE)[0]: @@ -219,6 +228,94 @@ def _load_from_file(self, theme_file_path): logging.error(exc) self.theme = {} self.theme_name = "" + + def _convert_device_theme(self, data): + """Convert a device-style theme JSON into the internal theme dict. + + This converts actions into state entries (only basic mapping). + Colors are converted from 0-255 per channel to 0-15 scale. + """ + def hex_to_rgb4(hexcol): + # Accept #RRGGBB or #AARRGGBB + if not hexcol or not isinstance(hexcol, str): + return [0, 0, 0] + s = hexcol.lstrip('#') + if len(s) == 6: + r = int(s[0:2], 16) + g = int(s[2:4], 16) + b = int(s[4:6], 16) + elif len(s) == 8: + r = int(s[2:4], 16) + g = int(s[4:6], 16) + b = int(s[6:8], 16) + else: + return [0, 0, 0] + # convert 0-255 to 0-15 + def to4(v): + return int(round(v / 17.0)) + return [to4(r), to4(g), to4(b)] + + # Build groupID -> name map + groups = {} + for g in data.get('groups', []): + gid = g.get('id') or g.get('groupID') + # prefer 'name' field + name = g.get('name') or g.get('groupID') or str(gid) + groups[gid] = name + + # zonesGroups: map zoneID -> groupID + zone_to_group = {} + for zg in data.get('zonesGroups', []): + zid = zg.get('zoneID') + gid = zg.get('groupID') + if zid is not None and gid is not None: + zone_to_group[zid] = gid + + # actions: each action has zoneID and color1/color2 + # We'll fold actions into the "Boot" state as fixed colours or morphs + theme = {} + theme['speed'] = data.get('theme', {}).get('tempo', 200) + boot_list = [] + for act in data.get('actions', []): + zid = act.get('zoneID') + gid = zone_to_group.get(zid) + zone_name = groups.get(gid, None) + if zone_name is None: + continue + action_type_id = act.get('actionTypeID') + # color1 / color2 fields + c1 = act.get('color1') or act.get('color') + c2 = act.get('color2') + if action_type_id == 3: # SetColor -> fixed + cols = [hex_to_rgb4(c1)] + loop = [{ + self.KW_ACTION_TYPE: self.KW_ACTION_TYPE_FIXED, + self.KW_ACTION_COLOURS: cols + }] + elif action_type_id in (1, 2, 4): # morph/pulse/loop -> approximate + if c2: + cols = [hex_to_rgb4(c1), hex_to_rgb4(c2)] + loop = [{ + self.KW_ACTION_TYPE: self.KW_ACTION_TYPE_MORPH, + self.KW_ACTION_COLOURS: cols + }] + else: + cols = [hex_to_rgb4(c1)] + loop = [{ + self.KW_ACTION_TYPE: self.KW_ACTION_TYPE_FIXED, + self.KW_ACTION_COLOURS: cols + }] + else: + # unknown action, skip + continue + item = { + self.KW_ZONES: [zone_name], + self.KW_LOOP: loop + } + boot_list.append(item) + if boot_list: + theme['Boot'] = boot_list + return theme def _save_to_file(self, theme_file_path): """ Save theme contents to a file.""" @@ -234,9 +331,15 @@ def _save_to_file(self, theme_file_path): def load(self, theme_name): """ Load a theme given its name. """ - theme_file = theme_name + ".json" - theme_file_path = os.path.join(self._theme_dir, theme_file) - self._load_from_file(theme_file_path) + theme_file_path = os.path.join(self._theme_dir, theme_name + ".json") + if os.path.exists(theme_file_path): + self._load_from_file(theme_file_path) + if self.theme and self.theme_name == "": + self.theme_name = theme_name + return + + self.set_default_theme() + self._save_to_file(theme_file_path) def save(self, theme_name=None): """ Save the current theme with the given name. diff --git a/alienfx/data/etc/udev/rules.d/10-alienfx.rules b/alienfx/data/etc/udev/rules.d/10-alienfx.rules index a17ad40..d4dd64f 100644 --- a/alienfx/data/etc/udev/rules.d/10-alienfx.rules +++ b/alienfx/data/etc/udev/rules.d/10-alienfx.rules @@ -2,6 +2,8 @@ # allow user access of the AlienFX controllers for use by e.g. the alienfx # program. +# ===== USB-BASED ALIENWARE SYSTEMS ===== + # USB device 0x187c:0x0551 (AlienFX controller for M18R2 laptop) SUBSYSTEM=="usb", ATTR{idVendor}=="187c", ATTR{idProduct}=="0551", MODE:="666", GROUP="users" @@ -58,3 +60,14 @@ SUBSYSTEM=="usb", ATTR{idVendor}=="187c", ATTR{idProduct}=="0528", MODE:="666", # USB device 0x187c:0x0518 (AlienFX controller for M18xR2 laptop) SUBSYSTEM=="usb", ATTR{idVendor}=="187c", ATTR{idProduct}=="0518", MODE:="666", GROUP="users" + + +# ===== ACPI/WMI-BASED ALIENWARE SYSTEMS ===== + +# Alienware Alpha ASM100 (uses ACPI/WMI via alienware-wmi kernel module, NOT USB) +# NOTE: Alpha ASM100 does NOT have a USB AlienFX controller +# Lighting is controlled through /sys/devices/platform/alienware-wmi/ +# Ensure alienware-wmi kernel module is loaded (add to /etc/modules-load.d/alienware.conf) + +# ACPI/WMI device alienware-wmi (AlienFX controller for Alienware Alpha) +SUBSYSTEM=="platform", ATTR{idVendor}=="187c", ATTR{idProduct}=="ASM100", MODE:="666", GROUP="users", DRIVER=="alienware-wmi", RUN+="/bin/chmod 0666 /sys/devices/platform/alienware-wmi/*" diff --git a/alienfx/data/themes/asm100_red.json b/alienfx/data/themes/asm100_red.json new file mode 100644 index 0000000..f0b50f1 --- /dev/null +++ b/alienfx/data/themes/asm100_red.json @@ -0,0 +1,168 @@ +{ + "speed": 200, + "Boot": [ + { + "zones": [ + "Power Button", + "Alien Head" + ], + "loop": [ + { + "type": "fixed", + "colours": [ + [ + 15, + 0, + 0 + ] + ] + } + ] + }, + { + "zones": [ + "Side Left" + ], + "loop": [ + { + "type": "fixed", + "colours": [ + [ + 15, + 0, + 0 + ] + ] + } + ] + } + ], + "AC Sleep": [ + { + "zones": [ + "Power Button", + "Alien Head", + "Side Left" + ], + "loop": [ + { + "type": "blink", + "colours": [ + [ + 0, + 0, + 15 + ], + [ + 0, + 0, + 0 + ] + ] + }, + { + "type": "morph", + "colours": [ + [ + 0, + 0, + 15 + ], + [ + 0, + 0, + 15 + ] + ] + } + ] + }, + { + "zones": [ + "Left Keyboard", + "Middle-left Keyboard", + "Middle-right Keyboard", + "Right Keyboard", + "Right Speaker", + "Left Speaker", + "Logo", + "Touchpad", + "Media Bar" + ], + "loop": [ + { + "type": "fixed", + "colours": [ + [ + 0, + 0, + 0 + ] + ] + } + ] + } + ], + "AC Charged": [ + { + "zones": [ + "Power Button", + "Alien Head", + "Side Left" + ], + "loop": [ + { + "type": "fixed", + "colours": [ + [ + 5, + 0, + 14 + ] + ] + } + ] + } + ], + "AC Charging": [ + { + "zones": [ + "Power Button", + "Alien Head", + "Side Left" + ], + "loop": [ + { + "type": "fixed", + "colours": [ + [ + 0, + 0, + 15 + ], + [ + 15, + 9, + 0 + ] + ] + }, + { + "type": "morph", + "colours": [ + [ + 15, + 9, + 0 + ], + [ + 0, + 0, + 15 + ] + ] + } + ] + } + ] +} \ No newline at end of file diff --git a/alienfx/data/themes/default.json b/alienfx/data/themes/default.json index d45efb7..6ca4c56 100644 --- a/alienfx/data/themes/default.json +++ b/alienfx/data/themes/default.json @@ -99,6 +99,15 @@ "colours": [ [0,0,15] ] } ] + }, + { + "zones": ["Power Button","Alien Head","Side Left"], + "loop": [ + { + "type": "fixed", + "colours": [ [15,0,0] ] + } + ] } ], "AC Sleep": [ diff --git a/alienfx/ui/console/main.py b/alienfx/ui/console/main.py index 1def179..9ccf7d6 100644 --- a/alienfx/ui/console/main.py +++ b/alienfx/ui/console/main.py @@ -34,6 +34,9 @@ import alienfx.core.themefile as alienfx_themefile import alienfx.core.logger as alienfx_logger import alienfx.core.zonescanner as alienfx_zonescanner +from alienfx.core.colorutil import parse_rgb_string +import json +from alienfx import Alienware, Zone import sys @@ -66,7 +69,6 @@ def doZonescan(): def start(): """ Main entry point for the alienfx cli.""" - print("You are running alienfx under Python-Version: "+sys.version) # You may switch the commenting of the following 2 lines to force zonescan-execution controller = AlienFXProber.get_controller() # DEBUG: you may comment this out for development of zonescanner @@ -113,12 +115,86 @@ def start(): argparser.add_argument( "-z", "--zonescan", action="store_true", help="starts a zonescan" ) + argparser.add_argument( + "-c", "--connector", action="store_true", help="show HDMI connector state" + ) + argparser.add_argument( + "-e", "--led-state", action="store_true", help="show LED state" + ) + argparser.add_argument( + "-H", "--head", help="set head LED (name or 'R G B')" + ) + argparser.add_argument( + "-L", "--left", help="set left LED (name or 'R G B')" + ) + argparser.add_argument( + "-R", "--right", help="set right LED (name or 'R G B')" + ) + argparser.add_argument( + "-j", "--json", action="store_true", help="output JSON for machine readability" + ) args = argparser.parse_args() if args.zonescan is not None: if args.zonescan: doZonescan() return True + # New sysfs-based quick commands (use the Alienware helper) + if any([args.connector, args.led_state, args.head, args.left, args.right]): + aw = Alienware() + json_data = {} + if args.connector: + try: + hdmi = aw.get_hdmi() + if args.json: + json_data['hdmi'] = { + 'exists': hdmi.exists, + 'input': str(hdmi.cable_state), + 'output': str(hdmi.source), + } + else: + print("HDMI passthrough state:", "present" if hdmi.exists else "not present") + if hdmi.exists: + print(" Input HDMI is {}".format(hdmi.cable_state)) + print(" Output HDMI is connected to {}".format(hdmi.source)) + print() + except PermissionError: + print("You do not have permission to run this command (do you need sudo?)") + + if args.led_state: + try: + leds = aw.get_rgb_zones() + if args.json: + leds_data: dict = {'exists': leds.exists} + for zone, val in leds.zones.items(): + leds_data[str(zone)] = {'red': val.red, 'green': val.green, 'blue': val.blue} + json_data['leds'] = leds_data + else: + print("LED state:", "present" if leds.exists else "not present") + if leds.exists: + for zone, val in leds.zones.items(): + print(" {}:".format(zone)) + print(" red: {}".format(val.red)) + print(" green: {}".format(val.green)) + print(" blue: {}".format(val.blue)) + print() + except PermissionError: + print("You do not have permission to run this command (do you need sudo?)") + + + for arg_name, zone in [(args.head, Zone.Head), (args.left, Zone.Left), (args.right, Zone.Right)]: + if arg_name is not None: + rgb = parse_rgb_string(arg_name) + if rgb is not None: + try: + aw.set_rgb_zone(zone, rgb[0], rgb[1], rgb[2]) + except PermissionError: + print("You do not have permission to run this command (do you need sudo?)") + + if args.json: + print(json.dumps(json_data)) + return True + if args.log is not None: alienfx_logger.set_logfile(args.log) if args.list is not None: @@ -132,4 +208,4 @@ def start(): themefile.applied() except Exception as e: - logging.error(e) + logging.error(str(e) + "\n Python-Version: "+sys.version) diff --git a/alienfx/ui/gtkui/__init__.py b/alienfx/ui/gtkui/__init__.py index 49d576d..bae8503 100644 --- a/alienfx/ui/gtkui/__init__.py +++ b/alienfx/ui/gtkui/__init__.py @@ -1,4 +1,10 @@ from __future__ import absolute_import -from alienfx.ui.gtkui.gtkui import start + + +def start(*args, **kwargs): + from alienfx.ui.gtkui.gtkui import start as gtkui_start + + return gtkui_start(*args, **kwargs) + # start() # debug (needed for debugging in pycharm) diff --git a/alienfx/ui/gtkui/action_renderer.py b/alienfx/ui/gtkui/action_renderer.py index d44fd33..e15a0ef 100644 --- a/alienfx/ui/gtkui/action_renderer.py +++ b/alienfx/ui/gtkui/action_renderer.py @@ -32,7 +32,7 @@ """ -from past.utils import old_div +"""Compatibility: avoid dependency on `past` by using native division.""" import cairo import gi @@ -73,7 +73,7 @@ def __init__(self, treeview, max_colour_val): prop = GObject.Value() prop.init(GObject.TYPE_INT) treeview.style_get_property("horizontal-separator", prop) - self.cell_padding = old_div(prop.get_int(),2) + self.cell_padding = prop.get_int() / 2 treeview.style_get_property("grid-line-width", prop) self.cell_padding += prop.get_int() self.selected_action = None @@ -81,8 +81,8 @@ def __init__(self, treeview, max_colour_val): def _convert_x_to_action_index(self, x): """ Convert the given x coordinate to an action index. """ - return int(old_div((x - self.cell_padding - 1), - (self.item_width + self.item_spacing + self.line_width))) + return int((x - self.cell_padding - 1) / + (self.item_width + self.item_spacing + self.line_width)) def select_action_at_index(self, index): """ Select the action at the given index. """ @@ -120,15 +120,16 @@ def do_render(self, cr, widget, background_area, cell_area, flags): # Draw the actions. actions = self.get_property("actions").actions start_x = cell_area.x + self.item_spacing - start_y = cell_area.y + old_div((cell_area.height - self.item_height),2) + start_y = cell_area.y + (cell_area.height - self.item_height) / 2 action_num = 0 for action in actions: action_type = AlienFXThemeFile.get_action_type(action) + border_colour = self.border_normal + (0.8,) if action_type == AlienFXThemeFile.KW_ACTION_TYPE_FIXED: colours = AlienFXThemeFile.get_action_colours(action) if len(colours) == 1: colours_normalized = [ - old_div(float(x),self.max_colour_val) for x in colours[0]] + float(x) / self.max_colour_val for x in colours[0]] if self._get_intensity(colours_normalized) > 0.5: border_colour = self.border_selected_dark else: @@ -142,25 +143,25 @@ def do_render(self, cr, widget, background_area, cell_area, flags): colours = AlienFXThemeFile.get_action_colours(action) if len(colours) == 1: colours_normalized = [ - old_div(float(x),self.max_colour_val) for x in colours[0]] + float(x) / self.max_colour_val for x in colours[0]] border_colour = self.border_selected_light (red, green, blue) = colours_normalized cr.rectangle( - start_x, start_y, old_div(self.item_width,2), self.item_height) + start_x, start_y, self.item_width / 2, self.item_height) cr.set_source_rgb(red, green, blue) cr.fill() cr.rectangle( - start_x+ old_div(self.item_width,2), start_y, - old_div(self.item_width,2), self.item_height) + start_x + (self.item_width / 2), start_y, + self.item_width / 2, self.item_height) cr.set_source_rgb(0, 0, 0) cr.fill() elif action_type == AlienFXThemeFile.KW_ACTION_TYPE_MORPH: colours = AlienFXThemeFile.get_action_colours(action) if len(colours) == 2: colours_normalized1 = [ - old_div(float(x),self.max_colour_val) for x in colours[0]] + float(x) / self.max_colour_val for x in colours[0]] colours_normalized2 = [ - old_div(float(x),self.max_colour_val) for x in colours[1]] + float(x) / self.max_colour_val for x in colours[1]] border_colours = [ self.border_selected_light, self.border_selected_dark] if (self._get_intensity(colours_normalized1) + diff --git a/alienfx/ui/gtkui/gtkui.py b/alienfx/ui/gtkui/gtkui.py index c184431..17f1eb3 100644 --- a/alienfx/ui/gtkui/gtkui.py +++ b/alienfx/ui/gtkui/gtkui.py @@ -31,14 +31,17 @@ import os import sys +import logging import threading +import time +from importlib import resources import gi gi.require_version('Gtk', '3.0') from gi.repository import GObject from gi.repository import Gtk from gi.repository import Gdk -import pkg_resources +from gi.repository import GLib from alienfx.ui.gtkui.colour_palette import ColourPalette from alienfx.core.prober import AlienFXProber @@ -81,6 +84,9 @@ def __init__(self): self.action_type = self.themefile.KW_ACTION_TYPE_FIXED self.theme_edited = False self.set_theme_done = True + self.apply_error = None + self.apply_started_at = None + self.apply_timeout_seconds = 30 def enable_delete_theme_button(self, enable): """ Enable or disable the "Delete Theme" button.""" @@ -97,6 +103,12 @@ def ask_discard_changes(self): response = dialog.run() dialog.destroy() return response == Gtk.ResponseType.YES + + def _resource_path(self, *parts): + resource = resources.files("alienfx.ui.gtkui") + for part in parts: + resource = resource.joinpath(part) + return resource def on_action_new_theme_activate(self, widget): """ Handler for when the "New Theme" action is triggered.""" @@ -137,7 +149,7 @@ def on_action_save_theme_activate(self, widget): def on_action_save_theme_as_activate(self, widget): """ Handler for when the "Save Theme As" action is triggered.""" themes = self.themefile.get_themes() - saveas_theme_list_store = self.builder.get_object("saveas_theme_list store") + saveas_theme_list_store = self.builder.get_object("saveas_theme_list_store") saveas_theme_list_store.clear() for theme in themes: saveas_theme_list_store.append([theme]) @@ -190,7 +202,8 @@ def on_action_add_activate(self, widget): model = self.zone_list_view.get_model() actions = model[treeiter][1] new_action = self.themefile.make_zone_action(self.action_type, [[15, 15, 15]]) - actions.actions.insert(action_index+1, new_action) + insert_index = action_index + 1 if action_index is not None else len(actions.actions) + actions.actions.insert(insert_index, new_action) self.builder.get_object("toolbutton_delete").set_sensitive(True) model[treeiter][1] = actions self.set_theme_dirty(True) @@ -211,19 +224,55 @@ def set_theme_dirty(self, dirty): def set_theme(self): """ Set the current theme on the computer.""" - self.controller.set_theme(self.themefile) - self.themefile.applied() + try: + if self.controller is None: + raise RuntimeError("No AlienFX controller available") + self.controller.set_theme(self.themefile) + self.themefile.applied() + self.apply_error = None + except Exception as exc: + self.apply_error = str(exc) + logging.exception("Error applying theme") self.set_theme_done = True def set_theme_done_cb(self): """ This idle task updates the GUI when the theme has been sent to the AlienFX controller.""" + # Prevent UI from remaining frozen forever if controller apply blocks. + if (not self.set_theme_done and + self.apply_started_at is not None and + (time.monotonic() - self.apply_started_at) > self.apply_timeout_seconds): + self.apply_error = ( + "The controller may be unresponsive and is timed out after {}s.".format( + self.apply_timeout_seconds + ) + ) + self.set_theme_done = True + if self.set_theme_done: spinner = self.builder.get_object("spinner") spinner.stop() spinner.hide() - self.builder.get_object("statusbar").pop(self.context_id) + statusbar = self.builder.get_object("statusbar") + statusbar.pop(self.context_id) self.builder.get_object("toolbar").set_sensitive(True) + if self.apply_error is not None: + # Keep error visible in statusbar as requested. + statusbar.push(self.context_id, "Apply failed: {}".format(self.apply_error)) + main_window = self.builder.get_object("main_window") + dialog = Gtk.MessageDialog( + main_window, + Gtk.DialogFlags.MODAL, + Gtk.MessageType.ERROR, + Gtk.ButtonsType.CLOSE, + "Failed: {}".format(self.apply_error), + ) + dialog.run() + dialog.destroy() + self.apply_error = None + else: + statusbar.push(self.context_id, "Theme applied.") + self.apply_started_at = None return False else: return True @@ -238,8 +287,10 @@ def on_action_apply_activate(self, widget): spinner.show() spinner.start() self.set_theme_done = False - GObject.idle_add(self.set_theme_done_cb) - self.set_theme_thread = threading.Thread(target=self.set_theme) + self.apply_started_at = time.monotonic() + self.apply_timeout_seconds = 10 + GLib.idle_add(self.set_theme_done_cb) + self.set_theme_thread = threading.Thread(target=self.set_theme, daemon=True) self.set_theme_thread.start() def set_window_title(self, theme_name): @@ -253,18 +304,37 @@ def load_theme(self, theme_name=None): normal_zone_list_store = self.builder.get_object("normal_zone_list_store") power_zone_list_store = self.builder.get_object("power_zone_list_store") normal_zone_list_store.clear() + power_zone_list_store.clear() + # If controller isn't available, nothing to load + if not self.controller: + return zones = self.controller.zone_map for zone in zones: if zone in self.controller.power_zones: - power_zone_list_store.clear() - power_states = [ + # Get available power states from controller's state_map + # This handles both laptop (with battery states) and desktop (AC-only) controllers + power_states = [] + + # Add AC states if available + ac_states = [ self.controller.STATE_AC_SLEEP, self.controller.STATE_AC_CHARGED, self.controller.STATE_AC_CHARGING, + ] + for state in ac_states: + if state in self.controller.state_map: + power_states.append(state) + + # Add battery states if available (laptop controllers) + battery_states = [ self.controller.STATE_BATTERY_SLEEP, self.controller.STATE_BATTERY_ON, - self.controller.STATE_BATTERY_CRITICAL + self.controller.STATE_BATTERY_CRITICAL, ] + for state in battery_states: + if state in self.controller.state_map: + power_states.append(state) + for state in power_states: a = AlienFXActions() a.actions = self.themefile.get_zone_actions(state, zone) @@ -368,8 +438,8 @@ def on_radiobutton_power_zone_toggled(self, button): def on_activate(self, data=None): self.builder = Gtk.Builder() - self.builder.add_from_file(pkg_resources.resource_filename( - "alienfx.ui.gtkui", "glade/ui.glade")) + with resources.as_file(self._resource_path("glade", "ui.glade")) as ui_glade: + self.builder.add_from_file(str(ui_glade)) self.zone_list_view = self.builder.get_object("zone_list_view") self.zone_list_view.get_selection().set_mode(Gtk.SelectionMode.SINGLE) @@ -406,8 +476,10 @@ def on_activate(self, data=None): self.builder.connect_signals(self) main_window = self.builder.get_object("main_window") - main_window.set_icon_from_file(pkg_resources.resource_filename( - "alienfx", "data/icons/hicolor/scalable/apps/alienfx.svg")) + with resources.as_file(resources.files("alienfx").joinpath( + "data", "icons", "hicolor", "scalable", "apps", "alienfx.svg" + )) as icon_file: + main_window.set_icon_from_file(str(icon_file)) main_window.show_all() self.add_window(main_window) @@ -455,6 +527,8 @@ def on_radiobutton_morphing_colour_toggled(self, button): def on_colour_selected(self, sender=None, data=None): if self.selected_action is None: return + if sender is None: + return colour = sender.get_colour() (treeiter, action_index) = self.selected_action @@ -471,9 +545,10 @@ def on_colour_selected(self, sender=None, data=None): if self.action_type == self.themefile.KW_ACTION_TYPE_MORPH: if len(old_colours) != 2: old_colours.append([0, 0, 0]) - if sender.get_parent() == self.palette1: + parent = sender.get_parent() if sender is not None else None + if parent == self.palette1: old_colours[0] = colour - if sender.get_parent() == self.palette2: + if parent == self.palette2: old_colours[1] = colour self.themefile.set_action_colours(action, old_colours) model[treeiter][1] = actions @@ -495,7 +570,7 @@ def zone_item_selected(self, treeview, event): model = treeview.get_model() treeiter = model.get_iter(path) actions = model[treeiter][1] - if action_index < len(actions.actions): + if action_index is not None and action_index < len(actions.actions): self.enable_action_edit_controls(True) self.selected_action = (treeiter, action_index) if len(actions.actions) == 1: diff --git a/docs/Knowledgebase/ACPI-WMI Controller Support.md b/docs/Knowledgebase/ACPI-WMI Controller Support.md new file mode 100644 index 0000000..37944bb --- /dev/null +++ b/docs/Knowledgebase/ACPI-WMI Controller Support.md @@ -0,0 +1,82 @@ +# ACPI/WMI Controller Support + +## Overview + +Some Alienware systems (like the Alienware Alpha ASM100) use ACPI/WMI interface for lighting control instead of USB. This requires the `alienware-wmi` kernel module. + +## Supported Systems + +- **Alienware Alpha ASM100** - Compact desktop with minimal lighting zones (Alienhead, Side Left) + +## Requirements + +### 1. Load the Kernel Module + +```bash +sudo modprobe alienware-wmi +``` + +### 2. Verify the Module is Loaded + +```bash +ls /sys/devices/platform/alienware-wmi +``` + +You should see sysfs attributes for controlling the lighting. + +### 3. Make the Module Load at Boot (Optional) + +Add to `/etc/modules-load.d/alienware. conf`: +``` +alienware-wmi +``` + +## Differences from USB Controllers + +1. **No USB VID/PID** - ACPI controllers are identified by the sysfs path +2. **Different Communication** - Uses sysfs file I/O instead of USB control transfers +3. **Limited Status** - Some status checking features may not be available +4. **Requires Root/Permissions** - You may need appropriate permissions to write to sysfs + +## Troubleshooting + +### Module Not Found +``` +ERROR: No AlienFX ACPI/WMI controller found +``` + +**Solution:** +```bash +# Check if module exists +modinfo alienware-wmi + +# Load the module +sudo modprobe alienware-wmi + +# Check kernel messages +dmesg | grep alienware +``` + +### Permission Denied + +**Solution:** Run with sudo or add udev rules: + +Create `/etc/udev/rules.d/99-alienware-wmi.rules`: +``` +SUBSYSTEM=="platform", DRIVER=="alienware-wmi", RUN+="/bin/chmod 0666 /sys/devices/platform/alienware-wmi/*" +``` + +Then reload udev: +```bash +sudo udevadm control --reload-rules +sudo udevadm trigger +``` + +## Implementation Details + +The ACPI controller (`AlienFXACPIController`) uses: +- `AlienFXACPIDriver` for sysfs communication +- Same command packet format as USB controllers +- Default path: `/sys/devices/platform/alienware-wmi` + +Subclasses (like `AlienFXControllerASM100`) inherit from `AlienFXACPIController` instead of the standard USB `AlienFXController`. diff --git a/docs/man/alienfx.1 b/docs/man/alienfx.1 index 210113e..e5f1851 100644 --- a/docs/man/alienfx.1 +++ b/docs/man/alienfx.1 @@ -1,7 +1,7 @@ .\" DO NOT MODIFY THIS FILE! It was generated by help2man 1.49.3. -.TH ALIENFX "1" "October 2023" "alienfx 2.4.3" "User Commands" +.TH ALIENFX "1" "October 2023" "alienfx 2.4.4" "User Commands" .SH NAME -alienfx \- manual page for alienfx 2.4.3 +alienfx \- manual page for alienfx 2.4.4 .SH SYNOPSIS .B alienfx [\fIOPTION\fP]... diff --git a/docs/man/alienfx.1.gz b/docs/man/alienfx.1.gz new file mode 100644 index 0000000000000000000000000000000000000000..091eae69a24f67579d215adbfb1a8bd106d43354 GIT binary patch literal 642 zcmV-|0)71-iwFoSB7tcF|6y!tWo~A8E-?UIRKafIFc7`_D`rupy|iXads%L@gklK@ zYORW zO5QTb;$_O>MJAW;l+utnj*`b`7Pd)i5pmeTy=6DWyMLB2i=V=q!~Uar6y9xkZr=%A zF3HH(V28>Aht{c5xji@yYh62|OK^pNQssqHbq+#Gv33@;guQMJgyJ1Hf~OqV2+57<^F zpRXaj;sPgzYhnw%LnQ3L??5H2mjiGNAU>5lRhO9U73mO4ogcQy&L}5fBOFIE8^B2J zn@}~*sS4b^Dn7G=bG|mn+5v`sx@}^GyA<%NUjzzqdcv~jjg`uJY~ufMsHnvjS@Bi* z9m$>mw&ws_VJtH4#A%Gq4X=J$I7FasD{tkKM(C)~-w~UQ9@vbfuO#)f@opf4V+~F{ zsJua1My=W)iuX;&P8wYy2Ua-O*kQk)D_68DistA$!`J^RZqXz7+lv0dtZ3 +# Copyright (C) 2015-2024 Track Master Steve +# +# You may redistribute it and/or modify it under the terms of the +# GNU General Public License, as published by the Free Software +# Foundation; either version 3 of the License, or (at your option) +# any later version. +# +# Alienfx is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. +# See the GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with alienfx. If not, write to: +# The Free Software Foundation, Inc., +# 51 Franklin Street, Fifth Floor +# Boston, MA 02110-1301, USA. +# + +""" +AlienFX Uninstaller +This script removes the AlienFX application and all its files from a Linux system. +""" + +import os +import sys +import shutil +import subprocess +from pathlib import Path + +# Color codes for terminal output +class Colors: + HEADER = '\033[95m' + OKBLUE = '\033[94m' + OKCYAN = '\033[96m' + OKGREEN = '\033[92m' + WARNING = '\033[93m' + FAIL = '\033[91m' + ENDC = '\033[0m' + BOLD = '\033[1m' + +def print_header(message): + print(f"\n{Colors.HEADER}{Colors.BOLD}{message}{Colors.ENDC}") + +def print_success(message): + print(f"{Colors.OKGREEN}✓ {message}{Colors.ENDC}") + +def print_warning(message): + print(f"{Colors.WARNING}⚠ {message}{Colors.ENDC}") + +def print_error(message): + print(f"{Colors.FAIL}✗ {message}{Colors.ENDC}") + +def print_info(message): + print(f"{Colors.OKCYAN}ℹ {message}{Colors.ENDC}") + +def remove_file(filepath): + """Remove a single file.""" + try: + if os.path.exists(filepath): + os.remove(filepath) + print_success(f"Removed: {filepath}") + return True + else: + print_warning(f"Not found: {filepath}") + return False + except Exception as e: + print_error(f"Failed to remove {filepath}: {e}") + return False + +def remove_directory(dirpath): + """Remove a directory and all its contents.""" + try: + if os.path.exists(dirpath): + shutil.rmtree(dirpath) + print_success(f"Removed directory: {dirpath}") + return True + else: + print_warning(f"Directory not found: {dirpath}") + return False + except Exception as e: + print_error(f"Failed to remove directory {dirpath}: {e}") + return False + +def get_python_site_packages(): + """Get the site-packages directory path.""" + try: + import site + site_packages = site.getsitepackages() + return site_packages + except Exception as e: + print_warning(f"Could not determine site-packages location: {e}") + return [] + +def uninstall_pip_package(): + """Attempt to uninstall via pip.""" + print_header("Attempting to uninstall via pip...") + try: + result = subprocess.run( + [sys.executable, "-m", "pip", "uninstall", "-y", "alienfx"], + capture_output=True, + text=True + ) + if result.returncode == 0: + print_success("Successfully uninstalled via pip") + return True + else: + print_warning("Package not found in pip or pip uninstall failed") + return False + except Exception as e: + print_warning(f"Could not uninstall via pip: {e}") + return False + +def remove_data_files(): + """Remove data files installed by the application.""" + print_header("Removing data files...") + + # Define all data files and directories to remove + data_files = [ + "/usr/share/applications/alienfx.desktop", + "/usr/local/share/applications/alienfx.desktop", + "/usr/share/icons/hicolor/scalable/apps/alienfx.svg", + "/usr/local/share/icons/hicolor/scalable/apps/alienfx.svg", + "/usr/share/icons/hicolor/48x48/apps/alienfx.png", + "/usr/local/share/icons/hicolor/48x48/apps/alienfx.png", + "/usr/share/pixmaps/alienfx.png", + "/usr/local/share/pixmaps/alienfx.png", + "/usr/share/man/man1/alienfx.1", + "/usr/local/share/man/man1/alienfx.1", + "/usr/share/man/man1/alienfx.1.gz", + "/usr/local/share/man/man1/alienfx.1.gz", + "~/.config/alienfx", + ] + + removed_count = 0 + for filepath in data_files: + if remove_file(filepath): + removed_count += 1 + + return removed_count + +def remove_udev_rules(): + """Remove udev rules file.""" + print_header("Removing udev rules...") + + udev_file = "/etc/udev/rules.d/10-alienfx.rules" + if remove_file(udev_file): + # Reload udev rules + try: + subprocess.run(["udevadm", "control", "--reload-rules"], check=False) + subprocess.run(["udevadm", "trigger"], check=False) + print_success("Reloaded udev rules") + except Exception as e: + print_warning(f"Could not reload udev rules: {e}") + return True + return False + +def remove_executables(): + """Remove executable scripts.""" + print_header("Removing executable scripts...") + + executables = [ + "/usr/bin/alienfx", + "/usr/local/bin/alienfx", + "/usr/bin/alienfx-gtk", + "/usr/local/bin/alienfx-gtk", + ] + + removed_count = 0 + for exe in executables: + if remove_file(exe): + removed_count += 1 + + return removed_count + +def remove_package_files(): + """Remove Python package files.""" + print_header("Removing Python package files...") + + removed_count = 0 + site_packages_dirs = get_python_site_packages() + + # Also check common locations + site_packages_dirs.extend([ + "/usr/lib/python3/dist-packages", + "/usr/local/lib/python3/dist-packages", + ]) + + for site_dir in site_packages_dirs: + if not os.path.exists(site_dir): + continue + + # Remove alienfx package directory + alienfx_dir = os.path.join(site_dir, "alienfx") + if remove_directory(alienfx_dir): + removed_count += 1 + + # Remove egg-info directory + for item in Path(site_dir).glob("alienfx-*.egg-info"): + if remove_directory(str(item)): + removed_count += 1 + + # Remove dist-info directory + for item in Path(site_dir).glob("alienfx-*.dist-info"): + if remove_directory(str(item)): + removed_count += 1 + + return removed_count + +def update_desktop_database(): + """Update desktop database and icon cache.""" + print_header("Updating system caches...") + + try: + # Update desktop database + subprocess.run( + ["update-desktop-database", "/usr/share/applications"], + stderr=subprocess.DEVNULL, + check=False + ) + subprocess.run( + ["update-desktop-database", "/usr/local/share/applications"], + stderr=subprocess.DEVNULL, + check=False + ) + print_success("Updated desktop database") + except Exception as e: + print_warning(f"Could not update desktop database: {e}") + + try: + # Update icon cache + subprocess.run( + ["gtk-update-icon-cache", "/usr/share/icons/hicolor"], + stderr=subprocess.DEVNULL, + check=False + ) + print_success("Updated icon cache") + except Exception as e: + print_warning(f"Could not update icon cache: {e}") + +def check_permissions(): + """Check if script is run with sufficient permissions.""" + if os.geteuid() != 0: + print_warning("This script is not running as root.") + print_info("Some files may require root privileges to remove.") + response = input("Continue anyway? [y/N]: ") + if response.lower() != 'y': + print_info("Uninstallation cancelled.") + sys.exit(0) + +def main(): + """Main uninstaller function.""" + print_header("═" * 60) + print_header("AlienFX Uninstaller") + print_header("═" * 60) + + # Check permissions + check_permissions() + + # Confirm uninstallation + print_info("\nThis will remove AlienFX and all its files from your system.") + response = input("Do you want to continue? [y/N]: ") + if response.lower() != 'y': + print_info("Uninstallation cancelled.") + sys.exit(0) + + # Perform uninstallation steps + total_removed = 0 + + # Try pip uninstall first + if uninstall_pip_package(): + total_removed += 1 + + # Remove package files manually + total_removed += remove_package_files() + + # Remove executables + total_removed += remove_executables() + + # Remove data files + total_removed += remove_data_files() + + # Remove udev rules + if remove_udev_rules(): + total_removed += 1 + + # Update system caches + update_desktop_database() + + # Final summary + print_header("═" * 60) + if total_removed > 0: + print_success(f"\nUninstallation complete! Removed {total_removed} items.") + else: + print_warning("\nNo AlienFX files were found on the system.") + print_header("═" * 60) + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + print_error("\n\nUninstallation cancelled by user.") + sys.exit(1) + except Exception as e: + print_error(f"\n\nUnexpected error: {e}") + sys.exit(1) diff --git a/requirements.txt b/requirements.txt index 68d5c8d..073a66b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,5 @@ pyusb>=1.2.1 setuptools>=68.0.0 future>=0.18.3 +PyGObject>=3.42.0 +pycairo>=1.20.0 diff --git a/setup.py b/setup.py index 943eeb0..d572e57 100644 --- a/setup.py +++ b/setup.py @@ -24,23 +24,35 @@ try: from setuptools import setup, find_packages -except ImportError: - print("ImportError: Unable to import 'setup' and 'find_packages' from setuptools.") - #import ez_setup - #ez_setup.use_setuptools() - #from setuptools import setup, find_packages +except ImportError as exc: + raise SystemExit( + "ImportError: Unable to import 'setup' and 'find_packages' from setuptools." + ) from exc import os import os.path from importlib import resources +from collections.abc import Sequence import shutil +import gzip -data_files = [ +man1 = "docs/man/alienfx.1" +man1_gz = "docs/man/alienfx.1.gz" +# Ensure a gzipped manpage exists so installations provide alienfx.1.gz +try: + if os.path.exists(man1) and not os.path.exists(man1_gz): + with open(man1, "rb") as f_in, gzip.open(man1_gz, "wb") as f_out: + shutil.copyfileobj(f_in, f_out) +except Exception: + # If gz creation fails, fall back to installing the uncompressed manpage + man1_gz = man1 + +data_files: list[tuple[str, Sequence[str]]] = [ ("share/applications", ["alienfx/data/share/applications/alienfx.desktop"]), ("share/icons/hicolor/scalable/apps", ["alienfx/data/icons/hicolor/scalable/apps/alienfx.svg"]), ("share/icons/hicolor/48x48/apps", ["alienfx/data/icons/hicolor/48x48/apps/alienfx.png"]), - ('share/pixmaps', ["alienfx/data/pixmaps/alienfx.png"]), - ("share/man/man1", ["docs/man/alienfx.1"]) + ("share/pixmaps", ["alienfx/data/pixmaps/alienfx.png"]), + ("share/man/man1", [man1_gz]) ] entry_points = { @@ -54,7 +66,7 @@ setup( name = "alienfx", - version = "2.4.3", + version = "2.4.4", fullname = "AlienFX Configuration Utility", description = "AlienFX Configuration Utility", author = "Track Master Steve", @@ -86,6 +98,6 @@ elif not os.access(udev_rules_dir, os.W_OK): print("Udev rules directory {} is not writable. Will not copy udev rules file.".format(udev_rules_dir)) else: - shutil.copy(udev_file, udev_rules_dir) + shutil.copy(str(udev_file), udev_rules_dir) except IOError: print("Unable to copy udev rules file {} to {}".format(udev_file, udev_rules_dir)) From 151558ccbf7a53cfffc98126f5fba267f0036654 Mon Sep 17 00:00:00 2001 From: "Sir.Dre" Date: Sun, 21 Jun 2026 12:05:00 -0600 Subject: [PATCH 2/5] Updates - Fix AlienFX new zone support - Added support for "Left Side" zone in both controller and controller_asm100. - Updated logic to handle legacy zone names. - Fixed UI element IDs. --- alienfx/core/controller.py | 3 ++- alienfx/core/controller_asm100.py | 25 ++++++++++++++++--------- alienfx/core/sysfs.py | 6 +++--- alienfx/core/themefile.py | 5 ++++- alienfx/ui/console/main.py | 8 ++++---- alienfx/ui/gtkui/glade/ui.glade | 4 ++-- alienfx/ui/gtkui/gtkui.py | 24 ++++++++++++++++++++---- 7 files changed, 51 insertions(+), 24 deletions(-) diff --git a/alienfx/core/controller.py b/alienfx/core/controller.py index e53b20a..15207e3 100644 --- a/alienfx/core/controller.py +++ b/alienfx/core/controller.py @@ -60,6 +60,7 @@ class AlienFXController(object): ZONE_RIGHT_SPEAKER = "Right Speaker" ZONE_LEFT_SPEAKER = "Left Speaker" ZONE_ALIEN_HEAD = "Alien Head" + ZONE_LEFT_SIDE = "Left Side" ZONE_LOGO = "Logo" ZONE_TOUCH_PAD = "Touchpad" ZONE_MEDIA_BAR = "Media Bar" @@ -156,7 +157,7 @@ def _wait_controller_ready(self): self._driver.write_packet(pkt) try: resp = self._driver.read_packet() - ready = (resp[0] == self.cmd_packet.STATUS_READY) + ready = bool(resp) and (resp[0] == self.cmd_packet.STATUS_READY) except TypeError: errcount += 1 logging.debug("No Status received yet... Failed tries=" + str(errcount)) diff --git a/alienfx/core/controller_asm100.py b/alienfx/core/controller_asm100.py index 1b85950..3fc2bfa 100644 --- a/alienfx/core/controller_asm100.py +++ b/alienfx/core/controller_asm100.py @@ -48,11 +48,13 @@ class AlienFXControllerASM100(acpi_controller.AlienFXACPIController): # Based on the JSON theme structure: # LED 0 - Alien Head (Group01) # LED 1 - Side Left (Group02) - ALIEN_HEAD = 0x0001 # ledID: 0 - SIDE_LEFT = 0x0002 # ledID: 1 + ALIEN_HEAD = 0x0000 # ledID: 0 + SIDE_LEFT = 0x0001 # ledID: 1 # Legacy compatibility (POWER_BUTTON may not exist as separate zone) POWER_BUTTON = ALIEN_HEAD # Map to Alienhead for backward compatibility + ZONE_LEFT_SIDE = "Left Side" + ZONE_LEFT_SIDE_LEGACY = "Side Left" # Reset codes RESET_ALL_LIGHTS_OFF = 3 @@ -97,14 +99,13 @@ def __init__(self): # Map zone names to their LED IDs (based on JSON leds array) self.zone_map = { self.ZONE_ALIEN_HEAD: self.ALIEN_HEAD, # LED 0 - Alien Head - self.ZONE_POWER_BUTTON: self.ALIEN_HEAD, # LED 0 - Mapped to Alien Head - "Side Left": self.SIDE_LEFT, # LED 1 - Side Left + self.ZONE_LEFT_SIDE: self.SIDE_LEFT, # LED 1 - Side Left } # LED groups mapping (from JSON groups and zonesGroups) self.led_groups = { - 1: "Alien Head", # Group01 - groupID: 1 - 2: "Side Left", # Group02 - groupID: 2 + 1: self.ALIEN_HEAD, # Group01 - groupID: 1 + 2: self.SIDE_LEFT, # Group02 - groupID: 2 } # LED definitions (from JSON leds array) @@ -125,7 +126,7 @@ def __init__(self): # Based on visualization types self.power_zones = [ self.ZONE_ALIEN_HEAD, - "Side Left", + self.ZONE_LEFT_SIDE, ] # Map reset names to their codes @@ -162,12 +163,18 @@ def __init__(self): } def _zone_name_to_sysfs_zone(self, zone_name): - if zone_name in (self.ZONE_ALIEN_HEAD, self.ZONE_POWER_BUTTON): + if zone_name == self.ZONE_ALIEN_HEAD: return Zone.Head - if zone_name == "Side Left": + if zone_name in (self.ZONE_LEFT_SIDE, self.ZONE_LEFT_SIDE_LEGACY): return Zone.Left return None + def get_zone_aliases(self, zone_name): + """Return canonical and legacy aliases for a zone name.""" + if zone_name == self.ZONE_LEFT_SIDE: + return (self.ZONE_LEFT_SIDE, self.ZONE_LEFT_SIDE_LEGACY) + return (zone_name,) + def _first_action_colour(self, themefile, item): for action in themefile.get_loop_items(item): colours = themefile.get_action_colours(action) diff --git a/alienfx/core/sysfs.py b/alienfx/core/sysfs.py index 021aa0c..397b618 100644 --- a/alienfx/core/sysfs.py +++ b/alienfx/core/sysfs.py @@ -144,8 +144,8 @@ def get_rgb_zones(self) -> RGBZones: exists = True rgb_root = self._sys_path("rgb_zones") if rgb_root.exists(): - # zone00 -> head, zone01 -> left, zone02 -> right - mapping = {"zone00": Zone.Head, "zone01": Zone.Left, "zone02": Zone.Right} + # zone00 -> head, zone01 -> left + mapping = {"zone00": Zone.Head, "zone01": Zone.Left} for fname, z in mapping.items(): fpath = rgb_root / fname if fpath.exists(): @@ -154,7 +154,7 @@ def get_rgb_zones(self) -> RGBZones: return RGBZones(zones=zones, exists=exists) def set_rgb_zone(self, zone: Zone, red: int, green: int, blue: int) -> None: - idx = {Zone.Head: "zone00", Zone.Left: "zone01", Zone.Right: "zone02"}[zone] + idx = {Zone.Head: "zone00", Zone.Left: "zone01"}[zone] path = self._sys_path("rgb_zones", idx) # write as hex pair each (two hex digits per component) like the rust code value = f"{red:02x}{green:02x}{blue:02x}" diff --git a/alienfx/core/themefile.py b/alienfx/core/themefile.py index ba7d695..c39ea6f 100644 --- a/alienfx/core/themefile.py +++ b/alienfx/core/themefile.py @@ -163,10 +163,13 @@ def get_zone_actions(self, state, zone): return [] if state not in self.theme: return [] + zone_candidates = (zone,) + if hasattr(self.controller, "get_zone_aliases"): + zone_candidates = tuple(self.controller.get_zone_aliases(zone)) for item in self.theme[state]: if self.KW_ZONES not in item: continue - if zone not in item[self.KW_ZONES]: + if not any(candidate in item[self.KW_ZONES] for candidate in zone_candidates): continue if self.KW_LOOP not in item: continue diff --git a/alienfx/ui/console/main.py b/alienfx/ui/console/main.py index 9ccf7d6..ed54498 100644 --- a/alienfx/ui/console/main.py +++ b/alienfx/ui/console/main.py @@ -159,7 +159,7 @@ def start(): print(" Output HDMI is connected to {}".format(hdmi.source)) print() except PermissionError: - print("You do not have permission to run this command (do you need sudo?)") + print("You do not have permission to run this command (sudo required.)") if args.led_state: try: @@ -179,17 +179,17 @@ def start(): print(" blue: {}".format(val.blue)) print() except PermissionError: - print("You do not have permission to run this command (do you need sudo?)") + print("You do not have permission to run this command (sudo required.)") - for arg_name, zone in [(args.head, Zone.Head), (args.left, Zone.Left), (args.right, Zone.Right)]: + for arg_name, zone in [(args.head, Zone.Head), (args.left, Zone.Left)]: if arg_name is not None: rgb = parse_rgb_string(arg_name) if rgb is not None: try: aw.set_rgb_zone(zone, rgb[0], rgb[1], rgb[2]) except PermissionError: - print("You do not have permission to run this command (do you need sudo?)") + print("You do not have permission to run this command (sudo required.)") if args.json: print(json.dumps(json_data)) diff --git a/alienfx/ui/gtkui/glade/ui.glade b/alienfx/ui/gtkui/glade/ui.glade index a575f3e..f8acf81 100644 --- a/alienfx/ui/gtkui/glade/ui.glade +++ b/alienfx/ui/gtkui/glade/ui.glade @@ -565,7 +565,7 @@ - + @@ -634,7 +634,7 @@ True False - saveas_theme_list store + saveas_theme_list_store diff --git a/alienfx/ui/gtkui/gtkui.py b/alienfx/ui/gtkui/gtkui.py index 17f1eb3..0cbd435 100644 --- a/alienfx/ui/gtkui/gtkui.py +++ b/alienfx/ui/gtkui/gtkui.py @@ -303,6 +303,7 @@ def load_theme(self, theme_name=None): theme file currently loaded.""" normal_zone_list_store = self.builder.get_object("normal_zone_list_store") power_zone_list_store = self.builder.get_object("power_zone_list_store") + normal_zones_added = set() normal_zone_list_store.clear() power_zone_list_store.clear() # If controller isn't available, nothing to load @@ -339,10 +340,25 @@ def load_theme(self, theme_name=None): a = AlienFXActions() a.actions = self.themefile.get_zone_actions(state, zone) power_zone_list_store.append([state, a]) - elif zone in self.controller.zone_map: # Is elif really necessary? Aren't we already iterating self.controller.zone_map? - a = AlienFXActions() - a.actions = self.themefile.get_zone_actions(self.controller.STATE_BOOT, zone) - normal_zone_list_store.append([zone, a]) + + # Keep compatibility with themes/controllers that use a dedicated + normal_states = [] + + zone_states = [ + self.controller.ZONE_ALIEN_HEAD, + self.controller.ZONE_POWER_BUTTON, + self.controller.ZONE_LEFT_SIDE, + ] + for state in zone_states: + if state in self.controller.power_zones: + normal_states.append(state) + + for state in normal_states: + a = AlienFXActions() + a.actions = self.themefile.get_zone_actions(self.controller.STATE_BOOT, state) + normal_zone_list_store.append([state, a]) + normal_zones_added.add(state) + self.zone_list_view.set_model(normal_zone_list_store) self.builder.get_object("radiobutton_normal_zones").set_active(True) if theme_name is not None: From 71c73309f067eb260837f45c10b915aa6388b2eb Mon Sep 17 00:00:00 2001 From: "Sir.Dre" Date: Sun, 21 Jun 2026 15:57:07 -0600 Subject: [PATCH 3/5] Updates - Fix GTK UI color handling --- ASM100_INTEGRATION.md | 16 ++++++++++++++-- alienfx/ui/gtkui/gtkui.py | 13 ++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/ASM100_INTEGRATION.md b/ASM100_INTEGRATION.md index 617003a..e64158c 100644 --- a/ASM100_INTEGRATION.md +++ b/ASM100_INTEGRATION.md @@ -34,12 +34,24 @@ The `load_theme()` method dynamically filters power states based on what the con ```bash pip install -r requirements.txt ``` +### Install +```bash +python setup.py install +python setup.py install_data +``` -### Launch GTK UI +### Launch ```bash -alienfx # or python3 -m alienfx.ui.gtkui.gtkui +sudo alienfx + +# or sudo python /usr/local/bin/alienfx-gtk + ``` +### Uninstall +```bash +python remove.py +``` The GTK UI will automatically detect your controller (USB or ACPI/WMI) and load appropriate zones and states. ## Color Utilities API diff --git a/alienfx/ui/gtkui/gtkui.py b/alienfx/ui/gtkui/gtkui.py index 0cbd435..880cd61 100644 --- a/alienfx/ui/gtkui/gtkui.py +++ b/alienfx/ui/gtkui/gtkui.py @@ -342,6 +342,7 @@ def load_theme(self, theme_name=None): power_zone_list_store.append([state, a]) # Keep compatibility with themes/controllers that use a dedicated + # power zone for the power button, even if the controller doesn't have a separate power button zone. normal_states = [] zone_states = [ @@ -556,11 +557,17 @@ def on_colour_selected(self, sender=None, data=None): if (self.action_type in [ self.themefile.KW_ACTION_TYPE_FIXED, self.themefile.KW_ACTION_TYPE_BLINK]): - if len(old_colours) != 1: - old_colours = old_colours[0:0] + if len(old_colours) == 0: + old_colours = [[0, 0, 0]] + elif len(old_colours) > 1: + old_colours = old_colours[0:1] if self.action_type == self.themefile.KW_ACTION_TYPE_MORPH: - if len(old_colours) != 2: + if len(old_colours) == 0: + old_colours = [[0, 0, 0], [0, 0, 0]] + elif len(old_colours) == 1: old_colours.append([0, 0, 0]) + elif len(old_colours) > 2: + old_colours = old_colours[0:2] parent = sender.get_parent() if sender is not None else None if parent == self.palette1: old_colours[0] = colour From 475c15b38ab2e56fa4ec16cb57069782c899d169 Mon Sep 17 00:00:00 2001 From: "Sir.Dre" Date: Tue, 23 Jun 2026 13:10:19 -0600 Subject: [PATCH 4/5] Updates - Fix controller detection and udev rule --- alienfx/core/prober.py | 19 +++++++++++++------ .../data/etc/udev/rules.d/10-alienfx.rules | 2 +- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/alienfx/core/prober.py b/alienfx/core/prober.py index 24f91ef..454defc 100644 --- a/alienfx/core/prober.py +++ b/alienfx/core/prober.py @@ -68,16 +68,21 @@ class AlienFXProber(object): @staticmethod def get_controller(): + """ Go through the supported_controllers list in AlienFXController + and see if any of them exist on the USB bus and ACPI, Return the first one + found, or None if none are found. + """ + # First check USB controllers - for controller in AlienFXController. supported_controllers: - vid = controller. vendor_id + for controller in AlienFXController.supported_controllers: + vid = controller.vendor_id pid = controller.product_id dev = usb.core.find(idVendor=vid, idProduct=pid) if dev is not None: return controller # Then check ACPI controllers - for controller in AlienFXACPIController. supported_controllers: + for controller in AlienFXACPIController.supported_controllers: # Check if ACPI path exists acpi_path = getattr(controller, 'acpi_path', '/sys/devices/platform/alienware-wmi') if os.path.exists(acpi_path): @@ -91,12 +96,14 @@ def find_controllers(vendor): vid = int(vendor, 16) # Convert our given Vendor-HEX-String to an equivalent intenger devs = usb.core.find(find_all=1) # All USB devices + if devs is None: + return None devices = [] # List of found AFX-Controllers for dev in devs: if dev is not None: - if dev.idVendor is not None: - if dev.idVendor == vid: - devices.append(dev) + dev_vendor = getattr(dev, 'idVendor', None) + if dev_vendor is not None and dev_vendor == vid: + devices.append(dev) if len(devices): return devices return None diff --git a/alienfx/data/etc/udev/rules.d/10-alienfx.rules b/alienfx/data/etc/udev/rules.d/10-alienfx.rules index d4dd64f..b8b5c89 100644 --- a/alienfx/data/etc/udev/rules.d/10-alienfx.rules +++ b/alienfx/data/etc/udev/rules.d/10-alienfx.rules @@ -70,4 +70,4 @@ SUBSYSTEM=="usb", ATTR{idVendor}=="187c", ATTR{idProduct}=="0518", MODE:="666", # Ensure alienware-wmi kernel module is loaded (add to /etc/modules-load.d/alienware.conf) # ACPI/WMI device alienware-wmi (AlienFX controller for Alienware Alpha) -SUBSYSTEM=="platform", ATTR{idVendor}=="187c", ATTR{idProduct}=="ASM100", MODE:="666", GROUP="users", DRIVER=="alienware-wmi", RUN+="/bin/chmod 0666 /sys/devices/platform/alienware-wmi/*" +SUBSYSTEM=="platform", ATTR{idVendor}=="187c", ATTR{idProduct}=="ASM100", MODE:="666", GROUP="users", DRIVER=="alienware-wmi", RUN+="/bin/chmod 666 /sys/devices/platform/alienware-wmi/*" From 4281b34767d39e5327e49c33ba0744214fa08a07 Mon Sep 17 00:00:00 2001 From: "Sir.Dre" Date: Tue, 23 Jun 2026 14:43:37 -0600 Subject: [PATCH 5/5] Updates - Add zone states in AlienFXApp for controller support --- alienfx/ui/gtkui/gtkui.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/alienfx/ui/gtkui/gtkui.py b/alienfx/ui/gtkui/gtkui.py index 880cd61..856c738 100644 --- a/alienfx/ui/gtkui/gtkui.py +++ b/alienfx/ui/gtkui/gtkui.py @@ -346,9 +346,22 @@ def load_theme(self, theme_name=None): normal_states = [] zone_states = [ + self.controller.ZONE_LEFT_KEYBOARD, + self.controller.ZONE_MIDDLE_LEFT_KEYBOARD, + self.controller.ZONE_MIDDLE_RIGHT_KEYBOARD, + self.controller.ZONE_RIGHT_KEYBOARD, + self.controller.ZONE_RIGHT_SPEAKER, + self.controller.ZONE_LEFT_SPEAKER, self.controller.ZONE_ALIEN_HEAD, - self.controller.ZONE_POWER_BUTTON, self.controller.ZONE_LEFT_SIDE, + self.controller.ZONE_LOGO, + self.controller.ZONE_TOUCH_PAD, + self.controller.ZONE_MEDIA_BAR, + self.controller.ZONE_STATUS_LEDS, + self.controller.ZONE_POWER_BUTTON, + self.controller.ZONE_HDD_LEDS, + self.controller.ZONE_RIGHT_DISPLAY, # LED-bar display right side, as built in the AW17R4 + self.controller.ZONE_LEFT_DISPLAY, # LED-bar display left side, as built in the AW17R4 ] for state in zone_states: if state in self.controller.power_zones: