From 5325af572990ffb8586e108155d32382a2a0d38a Mon Sep 17 00:00:00 2001 From: Yulian Kuncheff Date: Wed, 7 Jan 2026 00:15:48 +0100 Subject: [PATCH] Add ROG Laptop support, with initial GA403 support --- .gitignore | 1 + pyproject.toml | 1 + src/hhd/device/aura/__init__.py | 65 +++++--- src/hhd/device/rog_laptops/__init__.py | 112 +++++++++++++ src/hhd/device/rog_laptops/base.py | 122 +++++++++++++++ src/hhd/device/rog_laptops/const.py | 57 +++++++ src/hhd/device/rog_laptops/controllers.yml | 62 ++++++++ src/hhd/device/rog_laptops/fan.py | 173 +++++++++++++++++++++ src/hhd/device/rog_laptops/slash.py | 135 ++++++++++++++++ 9 files changed, 709 insertions(+), 19 deletions(-) create mode 100644 src/hhd/device/rog_laptops/__init__.py create mode 100644 src/hhd/device/rog_laptops/base.py create mode 100644 src/hhd/device/rog_laptops/const.py create mode 100644 src/hhd/device/rog_laptops/controllers.yml create mode 100644 src/hhd/device/rog_laptops/fan.py create mode 100644 src/hhd/device/rog_laptops/slash.py diff --git a/.gitignore b/.gitignore index e887b02e..9409cd6c 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist .vscode notebooks *.mo +build/ \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 87890ba7..dd6a2c8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ hhdctl = "hhd.http.ctl:main" adjustor = "adjustor.hhd:autodetect" legion_go = "hhd.device.legion_go:autodetect" rog_ally = "hhd.device.rog_ally:autodetect" +rog_laptops = "hhd.device.rog_laptops:autodetect" gpd_win = "hhd.device.gpd.win:autodetect" msi_claw = "hhd.device.claw:autodetect" onexplayer = "hhd.device.oxp:autodetect" diff --git a/src/hhd/device/aura/__init__.py b/src/hhd/device/aura/__init__.py index 7097a320..0bdd54df 100644 --- a/src/hhd/device/aura/__init__.py +++ b/src/hhd/device/aura/__init__.py @@ -7,7 +7,7 @@ from hhd.controller.base import RgbMode from hhd.controller.lib.hid import Device as HIDDevice -from hhd.controller.lib.hid import enumerate_unique +from hhd.controller.lib.hid import enumerate from hhd.i18n import _ from hhd.plugins import ( Config, @@ -48,9 +48,11 @@ AURA_CONFIGS = { # Z13 Lightbar - 0x18C6: (_("Lightbar"), AURA_CONFIGS_LIGHTBAR), + 0x18C6: (_("Lightbar"), AURA_CONFIGS_LIGHTBAR, "feature"), # ROG Keyboards (Z13 incl.) - 0x1A30: (_("Keyboard"), AURA_CONFIGS_USB), + 0x1A30: (_("Keyboard"), AURA_CONFIGS_USB, "feature"), + # GA403 Keyboard (note: 193B is Slash/Anime, not keyboard Aura) + 0x19B6: (_("Keyboard"), AURA_CONFIGS_USB, "output"), } AURA_CONFGIS_WMI = { @@ -62,6 +64,7 @@ RGB_INPUT_ID = 0x5A RGB_AURA_ID = 0x5D +# Note: 0x5E is for Slash/Anime, keyboard Aura uses 0x5D for all devices including 193B RGB_HANDSHAKE = lambda key: bytes( [ @@ -112,6 +115,7 @@ def init_rgb_dev(key: int, dev: HIDDevice): class AuraDevice(TypedDict): vid: int pid: int + report_id: int application: int fn: str name: str @@ -121,6 +125,8 @@ class AuraDevice(TypedDict): modes: dict[str, list[str]] dev: HIDDevice last_mode: RgbMode | None + method: Literal["feature", "output"] + last_cmd: bytes | None def buf(x): @@ -156,7 +162,7 @@ def get_aura_devices( out = {} found = set() - for d in enumerate_unique(vid=ASUS_VID): + for d in enumerate(vid=ASUS_VID): application = d.get("usage_page", 0x0000) << 16 | d.get("usage", 0x0000) if d["path"] in existing: ref = existing[d["path"]] @@ -175,7 +181,7 @@ def get_aura_devices( if d["product_id"] not in AURA_CONFIGS: continue - name, modes = AURA_CONFIGS[d["product_id"]] + name, modes, method = AURA_CONFIGS[d["product_id"]] cfg_name = f"{d['vendor_id']:04x}_{d['product_id']:04x}" try: @@ -195,6 +201,7 @@ def get_aura_devices( cfg_name=cfg_name, vid=d["vendor_id"], pid=d["product_id"], + report_id=RGB_AURA_ID, # 0x5D for all keyboard Aura devices application=application, fn=d["path"], init=True, @@ -202,8 +209,10 @@ def get_aura_devices( modes=modes, dev=dev, last_mode=None, + method=method, + last_cmd=None, ) - logger.info( + logger.debug( "Found Aura device %s (%s, %04x:%04x) with modes:\n%s", name, d["path"].decode("utf-8"), @@ -215,7 +224,7 @@ def get_aura_devices( updated = False for k in list(existing.keys()): if k not in found: - logger.info( + logger.debug( "Removing Aura device %s (%s, %04x:%04x) from known devices", existing[k]["fn"], existing[k]["name"], @@ -242,6 +251,7 @@ def rgb_command( o_red: int, o_green: int, o_blue: int, + report_id: int = RGB_AURA_ID, ): c_direction = 0x00 set_speed = True @@ -298,7 +308,7 @@ def rgb_command( c_zone = 0x00 return buf( [ - RGB_AURA_ID, + report_id, 0xB3, c_zone, # zone c_mode, # mode @@ -378,6 +388,7 @@ def get_aura_mode_cmd(cfg, dev: AuraDevice): red2 if color2_set else 0, green2 if color2_set else 0, blue2 if color2_set else 0, + dev["report_id"], ), mode, always_init, @@ -404,7 +415,7 @@ def set_aura_brightness( try: with open(WMI_LOCATION, "w") as f: f.write(str(c)) - logger.info("Set Aura brightness to %s", brightness) + logger.debug("Set Aura brightness to %s", brightness) return False except Exception as e: logger.error("Failed to set WMI brightness: %s", e) @@ -652,22 +663,37 @@ def update(self, conf: Config): try: if init: - init_rgb_dev(RGB_AURA_ID, d["dev"]) + if d["method"] == "feature": + init_rgb_dev(d["report_id"], d["dev"]) + if power_settings is not None: # Set power settings on init - d["dev"].send_feature_report( - get_aura_power_cmd(power_settings), - ) + cmd_power = get_aura_power_cmd(power_settings) + if d["method"] == "output": + d["dev"].write(cmd_power) + else: + d["dev"].send_feature_report(cmd_power) # Needs time to initialize, get it next cycle self.queue_apply[k] = curr_t + RGB_APPLY_DELAY else: - d["dev"].send_feature_report(cmd) + changed_cmd = cmd != d["last_cmd"] + if changed_cmd or chanded_mode or always_init or init: + if d["method"] == "output": + d["dev"].write(cmd) + else: + d["dev"].send_feature_report(cmd) + d["last_cmd"] = cmd + d["last_mode"] = new_mode - if chanded_mode or queued or always_init or init: - d["dev"].send_feature_report(RGB_SET(RGB_AURA_ID)) - d["dev"].send_feature_report(RGB_APPLY(RGB_AURA_ID)) + if changed_cmd or chanded_mode or queued or always_init or init: + if d["method"] == "output": + d["dev"].write(RGB_SET(d["report_id"])) + d["dev"].write(RGB_APPLY(d["report_id"])) + else: + d["dev"].send_feature_report(RGB_SET(d["report_id"])) + d["dev"].send_feature_report(RGB_APPLY(d["report_id"])) else: self.queue_apply[k] = curr_t + RGB_APPLY_DELAY @@ -701,7 +727,7 @@ def update(self, conf: Config): self.devices, updated = get_aura_devices(self.devices) if updated: - logger.info("Found %d Aura devices", len(self.devices)) + logger.debug("Found %d Aura devices", len(self.devices)) self.emit({"type": "settings"}) if error: self.emit({"type": "settings"}) @@ -784,9 +810,10 @@ def notify(self, events): 0, # o_red 0, # o_green 0, # o_blue + d["report_id"], ) d["dev"].send_feature_report(cmd) - d["dev"].send_feature_report(RGB_SET(RGB_AURA_ID)) + d["dev"].send_feature_report(RGB_SET(d["report_id"])) self.queue_apply[d["cfg_name"]] = ( time.perf_counter() + RGB_TDP_DELAY ) diff --git a/src/hhd/device/rog_laptops/__init__.py b/src/hhd/device/rog_laptops/__init__.py new file mode 100644 index 00000000..f4173cdf --- /dev/null +++ b/src/hhd/device/rog_laptops/__init__.py @@ -0,0 +1,112 @@ +from threading import Event as TEvent, Thread +from typing import Sequence + +from hhd.plugins import ( + Config, + Context, + Emitter, + Event, + HHDPlugin, + load_relative_yaml, + get_outputs_config, +) +from hhd.plugins.settings import HHDSettings + + +class RogLaptopControllersPlugin(HHDPlugin): + name = "rog_laptop_controllers" + priority = 18 + log = "rog_laptop" + + def __init__(self, target_device: str, report_id: int) -> None: + self.t = None + self.should_exit = None + self.updated = TEvent() + self.started = False + self.t = None + self.target_device = target_device + self.report_id = report_id + + def open( + self, + emit: Emitter, + context: Context, + ): + self.emit = emit + self.context = context + self.prev = None + + def settings(self) -> HHDSettings: + base = {"controllers": {"rog_laptops": load_relative_yaml("controllers.yml")}} + base["controllers"]["rog_laptops"]["children"]["controller_mode"].update( + get_outputs_config( + can_disable=True, + extra_buttons="none", + noob_default=True, + start_disabled=True, + ) + ) + return base + + def update(self, conf: Config): + import logging + logger = logging.getLogger(__name__) + + new_conf = conf["controllers.rog_laptops"] + + if new_conf == self.prev: + return + + if self.prev is None: + self.prev = new_conf + else: + self.prev.update(new_conf.conf) + + self.updated.set() + self.start(self.prev) + + def start(self, conf): + from .base import plugin_run + + if self.started: + return + self.started = True + + self.close() + self.should_exit = TEvent() + self.t = Thread( + target=plugin_run, + args=( + conf, + self.emit, + self.context, + self.should_exit, + self.updated, + self.target_device, + self.report_id, + ), + ) + self.t.start() + + def close(self): + if not self.should_exit or not self.t: + return + self.should_exit.set() + self.t.join() + self.should_exit = None + self.t = None + + +def autodetect(existing: Sequence[HHDPlugin]) -> Sequence[HHDPlugin]: + if len(existing): + return existing + + # Match product number + with open("/sys/devices/virtual/dmi/id/product_name") as f: + dmi = f.read().strip() + + # GA403UI + if "GA403" in dmi: + return [RogLaptopControllersPlugin(target_device="GA403", report_id=0x5D)] + + return [] diff --git a/src/hhd/device/rog_laptops/base.py b/src/hhd/device/rog_laptops/base.py new file mode 100644 index 00000000..fe80d892 --- /dev/null +++ b/src/hhd/device/rog_laptops/base.py @@ -0,0 +1,122 @@ + +import logging +import time +import select +from threading import Event as TEvent +from typing import Sequence, Literal + +from hhd.controller.physical.hidraw import GenericGamepadHidraw +from hhd.plugins import Config, Context, Emitter +from hhd.controller import Event + +from .const import GA403_PID_ALT +from .fan import FanControl +from .slash import SlashControl + +logger = logging.getLogger(__name__) + +ASUS_VID = 0x0B05 + +REPORT_DELAY_MAX = 0.5 + +class RogLaptopHidraw(GenericGamepadHidraw): + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self.slash: SlashControl | None = None + self.fans = FanControl() + + def open(self) -> Sequence[int]: + self.fds = super().open() + if self.dev: + alt_mode = False + if self.info and self.info.get('product_id') == GA403_PID_ALT: + alt_mode = True + logger.debug(f"Using ALT mode (193B) for Slash Lighting") + else: + logger.debug(f"Using standard mode for Slash Lighting (PID: {self.info.get('product_id') if self.info else 'unknown'})") + + self.slash = SlashControl(self.dev, alt_mode=alt_mode) + self.slash.init() + return self.fds + + def update_conf(self, conf: Config): + if self.slash: + try: + self.slash.update( + conf["slash_lighting"].to(bool), + conf["slash_pattern"]["mode"].to(str), + conf["slash_speed"].to(str), + conf["slash_brightness"].to(str), + ) + except Exception as e: + logger.warning(f"Error updating slash lighting: {e}") + + try: + mode = conf["fan_mode"].to(str) + if mode == "Auto": + self.fans.set_auto() + elif mode == "Quiet": + self.fans.set_quiet() + elif mode == "Performance": + self.fans.set_performance() + elif mode == "Max": + self.fans.set_max() + except Exception as e: + logger.warning(f"Error setting fan mode: {e}") + + +def plugin_run( + conf: Config, + emit: Emitter, + context: Context, + should_exit: TEvent, + updated: TEvent, + target_device: str, + report_id: int, +): + # Only support GA403 for now regarding PID + # Only use 193B (ALT) for Slash Lighting! + # 19B6 is a different interface that doesn't support Slash via Feature Reports. + # enumerate_unique sorts by path, so 19B6 (/dev/hidraw0) would be picked first. + pids = [GA403_PID_ALT] # 0x193B only + if target_device != "GA403": + logger.warning(f"Unknown device {target_device}, defaulting to PIDs {pids}") + + logger.info(f"RogLaptop: Loaded with PIDs: {pids} and strictly no usage page check.") + + while not should_exit.is_set(): + try: + logger.info(f"Starting Asus Laptop support for {target_device}...") + + d_hid = RogLaptopHidraw( + vid=[ASUS_VID], + pid=pids, + required=True, + report_size=64, + ) + + d_hid.open() + + d_hid.update_conf(conf) + + loop_count = 0 + while not should_exit.is_set(): + loop_count += 1 + if updated.is_set(): + updated.clear() + d_hid.update_conf(conf) + + # Slow loop as we are not polling high freq inputs from this device + start = time.time() + # Just wait, don't read - we're not processing input from this device + time.sleep(REPORT_DELAY_MAX) + + except Exception as e: + logger.error(f"Error in RogLaptop plugin: {e}") + time.sleep(3) + finally: + try: + d_hid.close() + except: + pass + diff --git a/src/hhd/device/rog_laptops/const.py b/src/hhd/device/rog_laptops/const.py new file mode 100644 index 00000000..7502761b --- /dev/null +++ b/src/hhd/device/rog_laptops/const.py @@ -0,0 +1,57 @@ +def buf(x): + return bytes(x) + bytes(64 - len(x)) + +# Slash Lighting (GA403) +# Report ID 0x5D +SLASH_INIT_1 = buf([0x5D, 0xD7, 0x00, 0x00, 0x01, 0xAC]) +SLASH_INIT_2 = buf([0x5D, 0xD2, 0x02, 0x01, 0x08, 0xAB]) +SLASH_ENABLE = buf([0x5D, 0xD8, 0x02, 0x00, 0x01, 0x00]) +SLASH_DISABLE = buf([0x5D, 0xD8, 0x02, 0x00, 0x01, 0x80]) +SLASH_APPLY = buf([0x5D, 0xD4, 0x00, 0x00, 0x01, 0xAB]) + +# Slash Lighting Alt (PID 193B) +# Report ID 0x5E +SLASH_INIT_1_ALT = buf([0x5E, 0xD7, 0x00, 0x00, 0x01, 0xAC]) +SLASH_INIT_2_ALT = buf([0x5E, 0xD2, 0x02, 0x01, 0x08, 0xAB]) +SLASH_ENABLE_ALT = buf([0x5E, 0xD8, 0x02, 0x00, 0x01, 0x00]) +SLASH_DISABLE_ALT = buf([0x5E, 0xD8, 0x02, 0x00, 0x01, 0x80]) +SLASH_APPLY_ALT = buf([0x5E, 0xD4, 0x00, 0x00, 0x01, 0xAB]) + +SLASH_SET_MODE_1_ALT = buf([0x5E, 0xD2, 0x03, 0x00, 0x0C]) +SLASH_SET_MODE_2_FLOW_ALT = buf([0x5E, 0xD3, 0x04, 0x00, 0x0C, 0x01, 0x19, 0x02, 0x19, 0x03, 0x13, 0x04, 0x11, 0x05, 0x12, 0x06, 0x13]) +SLASH_OPTIONS_ALT = buf([0x5E, 0xD3, 0x03, 0x01, 0x08, 0xAB, 0xFF, 0x01, 0x01, 0x06, 0xFF, 0xFF, 0x0A]) + +GA403_PID = 0x19B6 +GA403_PID_ALT = 0x193B + +SLASH_MODES = { + "Bounce": 0x10, + "Slash": 0x12, + "Loading": 0x13, + "BitStream": 0x1d, + "Transmission": 0x1a, + "Flow": 0x19, + "Flux": 0x25, + "Phantom": 0x24, + "Spectrum": 0x26, + "Hazard": 0x32, + "Interfacing": 0x33, + "Ramp": 0x34, + "GameOver": 0x42, + "Start": 0x43, + "Buzzer": 0x44, +} + +SLASH_SPEEDS = { + "Slow": 0x04, + "Normal": 0x03, + "Fast": 0x02, + "Turbo": 0x01, +} + +SLASH_BRIGHTNESS = { + "Low": 60, + "Medium": 120, + "High": 180, + "Max": 255, +} diff --git a/src/hhd/device/rog_laptops/controllers.yml b/src/hhd/device/rog_laptops/controllers.yml new file mode 100644 index 00000000..924b89b6 --- /dev/null +++ b/src/hhd/device/rog_laptops/controllers.yml @@ -0,0 +1,62 @@ +type: container +tags: [lgc] +title: Asus Laptop Control +hint: >- + Configuration for Asus ROG Laptops (Fans, Lighting). + +children: + slash_lighting: + type: bool + title: Slash Lighting + tags: [rog_slash] + hint: >- + Enable or disable the Slash Lighting on the lid of the laptop. + default: true + + slash_pattern: + type: mode + title: Slash Pattern + tags: [rog_slash] + default: Flow + hint: Set the Slash Lighting animation pattern. + modes: + Flow: { type: container, title: Flow, children: {} } + Bounce: { type: container, title: Bounce, children: {} } + Slash: { type: container, title: Slash, children: {} } + Loading: { type: container, title: Loading, children: {} } + BitStream: { type: container, title: BitStream, children: {} } + Transmission: { type: container, title: Transmission, children: {} } + Flux: { type: container, title: Flux, children: {} } + Phantom: { type: container, title: Phantom, children: {} } + Spectrum: { type: container, title: Spectrum, children: {} } + Hazard: { type: container, title: Hazard, children: {} } + Interfacing: { type: container, title: Interfacing, children: {} } + Ramp: { type: container, title: Ramp, children: {} } + GameOver: { type: container, title: Game Over, children: {} } + Start: { type: container, title: Start, children: {} } + Buzzer: { type: container, title: Buzzer, children: {} } + + slash_speed: + type: discrete + title: Slash Speed + tags: [rog_slash] + options: [Slow, Normal, Fast, Turbo] + default: Normal + hint: Set the animation speed. + + slash_brightness: + type: discrete + title: Slash Brightness + tags: [rog_slash] + options: [Low, Medium, High, Max] + default: Medium + hint: Create a custom brightness level. + + fan_mode: + type: discrete + title: Fan Control Mode + tags: [rog_fan] + options: [Auto, Quiet, Performance, Max] + default: Auto + hint: >- + Set fan control mode. Auto uses BIOS defaults. Quiet reduces fan noise. Performance increases cooling. Max sets fans to full speed. diff --git a/src/hhd/device/rog_laptops/fan.py b/src/hhd/device/rog_laptops/fan.py new file mode 100644 index 00000000..574e5032 --- /dev/null +++ b/src/hhd/device/rog_laptops/fan.py @@ -0,0 +1,173 @@ + +import logging +import os +from typing import Sequence + +logger = logging.getLogger(__name__) + +class FanControl: + def __init__(self): + self.path = None + self.current_mode = None + self.find_device() + + def find_device(self): + try: + base = "/sys/class/hwmon" + for dev in os.listdir(base): + path = os.path.join(base, dev) + name_path = os.path.join(path, "name") + if not os.path.exists(name_path): + continue + if not os.path.exists(name_path): + continue + with open(name_path, "r") as f: + name = f.read().strip() + logger.debug(f"Found hwmon device: {name} at {path}") + if name == "asus_custom_fan_curve": + self.path = path + logger.info(f"Found Asus fan control at {self.path}") + return + except Exception as e: + logger.error(f"Error finding fan device: {e}") + + logger.warning("Asus fan control device not found.") + + def set_curve(self, fan_idx: int, curve: Sequence[tuple[int, int]], enable: bool = True): + # fan_idx: 1=CPU, 2=GPU, 3=Mid + if not self.path: + return + + try: + # Write points + for i, (temp, pwm) in enumerate(curve): + if i >= 8: break + + # pwmX_auto_pointY_temp / pwm + # points are 1-indexed in sysfs, 0-indexed in list + pt = i + 1 + + t_path = os.path.join(self.path, f"pwm{fan_idx}_auto_point{pt}_temp") + p_path = os.path.join(self.path, f"pwm{fan_idx}_auto_point{pt}_pwm") + + with open(t_path, "w") as f: + f.write(str(temp)) + with open(p_path, "w") as f: + f.write(str(pwm)) + + # Enable/Disable + # 1 = Manual/Custom Curve (Enabled) + # 2 = Auto/Default (Disabled) + mode = "1" if enable else "2" + e_path = os.path.join(self.path, f"pwm{fan_idx}_enable") + with open(e_path, "w") as f: + f.write(mode) + + except Exception as e: + logger.error(f"Failed to set fan curve for fan {fan_idx}: {e}") + + def set_auto(self): + if self.current_mode == "Auto": + return + if not self.path: + return + + # Enable = 2 (Auto) for all fans (CPU=1, GPU=2, Mid=3) + for i in range(1, 4): + try: + e_path = os.path.join(self.path, f"pwm{i}_enable") + if os.path.exists(e_path): + with open(e_path, "w") as f: + f.write("2") + except Exception as e: + # Some might not exist (e.g. Mid) + pass + self.current_mode = "Auto" + # logger.info("Set fans to Auto mode.") + logger.debug("Set fans to Auto mode.") + + def set_max(self): + if self.current_mode == "Max": + return + if not self.path: + logger.warning("set_max: No fan control path found") + return + + # asus_custom_fan_curve uses pwmX_auto_pointY_pwm for fan curves + # Set enable=1 (custom curve) and all curve points to max + for i in range(1, 4): # pwm1, pwm2, pwm3 + try: + # Enable custom fan curve + e_path = os.path.join(self.path, f"pwm{i}_enable") + if os.path.exists(e_path): + with open(e_path, "w") as f: + f.write("1") + + # Set all 8 curve points to maximum (255) + for point in range(1, 9): # points 1-8 + p_path = os.path.join(self.path, f"pwm{i}_auto_point{point}_pwm") + if os.path.exists(p_path): + with open(p_path, "w") as f: + f.write("255") + except Exception as e: + logger.warning(f"set_max: Error setting pwm{i}: {e}") + self.current_mode = "Max" + logger.debug("Set fans to Max mode.") + + def set_quiet(self): + """Set fans to quiet mode - lower speeds to reduce noise.""" + if self.current_mode == "Quiet": + return + if not self.path: + logger.warning("set_quiet: No fan control path found") + return + + # Quiet curve: gradual ramp, lower max values + # Points are (temp, pwm) pairs - we set PWM values + quiet_curve = [30, 40, 50, 60, 80, 100, 120, 140] # Max 140 out of 255 + + for i in range(1, 4): # pwm1, pwm2, pwm3 + try: + e_path = os.path.join(self.path, f"pwm{i}_enable") + if os.path.exists(e_path): + with open(e_path, "w") as f: + f.write("1") + + for point, pwm in enumerate(quiet_curve, 1): + p_path = os.path.join(self.path, f"pwm{i}_auto_point{point}_pwm") + if os.path.exists(p_path): + with open(p_path, "w") as f: + f.write(str(pwm)) + except Exception as e: + logger.warning(f"set_quiet: Error setting pwm{i}: {e}") + self.current_mode = "Quiet" + logger.debug("Set fans to Quiet mode.") + + def set_performance(self): + """Set fans to performance mode - higher speeds for better cooling.""" + if self.current_mode == "Performance": + return + if not self.path: + logger.warning("set_performance: No fan control path found") + return + + # Performance curve: aggressive ramp, higher PWM values + perf_curve = [60, 80, 120, 160, 200, 230, 250, 255] # Max out at 255 + + for i in range(1, 4): # pwm1, pwm2, pwm3 + try: + e_path = os.path.join(self.path, f"pwm{i}_enable") + if os.path.exists(e_path): + with open(e_path, "w") as f: + f.write("1") + + for point, pwm in enumerate(perf_curve, 1): + p_path = os.path.join(self.path, f"pwm{i}_auto_point{point}_pwm") + if os.path.exists(p_path): + with open(p_path, "w") as f: + f.write(str(pwm)) + except Exception as e: + logger.warning(f"set_performance: Error setting pwm{i}: {e}") + self.current_mode = "Performance" + logger.debug("Set fans to Performance mode.") + diff --git a/src/hhd/device/rog_laptops/slash.py b/src/hhd/device/rog_laptops/slash.py new file mode 100644 index 00000000..bc0bae5e --- /dev/null +++ b/src/hhd/device/rog_laptops/slash.py @@ -0,0 +1,135 @@ + +import logging +from typing import Literal + +from .const import ( + SLASH_DISABLE, + SLASH_ENABLE, + SLASH_INIT_1, + SLASH_INIT_2, + SLASH_DISABLE_ALT, + SLASH_ENABLE_ALT, + SLASH_INIT_1_ALT, + SLASH_INIT_2_ALT, + SLASH_APPLY, + SLASH_APPLY_ALT, + SLASH_MODES, + SLASH_SPEEDS, + SLASH_BRIGHTNESS, + buf, +) + +logger = logging.getLogger(__name__) + +class SlashControl: + def __init__(self, dev, alt_mode: bool = False): + self.dev = dev + self.alt_mode = alt_mode + self.enabled = None + self.pattern = None + self.speed = None + self.brightness = None + + def init(self): + if not self.dev: + return + try: + if self.alt_mode: + # Use Feature Reports for 193B (Control Transfer 0x35E) + self.dev.send_feature_report(SLASH_INIT_1_ALT) + self.dev.send_feature_report(SLASH_INIT_2_ALT) + + # Default init options + self.update(True, "Flow", "Normal", "Medium", force=True) + else: + self.dev.write(SLASH_INIT_1) + self.dev.write(SLASH_INIT_2) + logger.debug("Initialized Slash Lighting.") + except Exception as e: + logger.error(f"Failed to initialize Slash Lighting: {e}") + + def _get_mode_packets(self, mode: str): + mode_byte = SLASH_MODES.get(mode, 0x19) # Default Flow + rid = 0x5E if self.alt_mode else 0x5D + pkt1 = buf([rid, 0xD2, 0x03, 0x00, 0x0C]) + pkt2 = buf([rid, 0xD3, 0x04, 0x00, 0x0C, 0x01, mode_byte, 0x02, 0x19, 0x03, 0x13, 0x04, 0x11, 0x05, 0x12, 0x06, 0x13]) + return pkt1, pkt2 + + def _get_options_packet(self, enable: bool, speed: str, brightness: str): + rid = 0x5E if self.alt_mode else 0x5D + status = 0x01 if enable else 0x00 + interval = SLASH_SPEEDS.get(speed, 0x03) + bright_val = SLASH_BRIGHTNESS.get(brightness, 120) + + return buf([rid, 0xD3, 0x03, 0x01, 0x08, 0xAB, 0xFF, 0x01, status, 0x06, bright_val, 0xFF, interval]) + + def update(self, enable: bool, pattern: str, speed: str, brightness: str, force: bool = False): + if not self.dev: + return + + if not force and ( + self.enabled == enable and + self.pattern == pattern and + self.speed == speed and + self.brightness == brightness + ): + return + + try: + # 1. Update Pattern if changed + if force or self.pattern != pattern: + p1, p2 = self._get_mode_packets(pattern) + logger.debug(f"Setting Slash Pattern to {pattern}") + if self.alt_mode: + self.dev.send_feature_report(p1) + self.dev.send_feature_report(p2) + else: + self.dev.write(p1) + self.dev.write(p2) + + # 2. Update Options (Brightness/Speed) or Enable Status if changed + # The options packet contains Status, Brightness AND Interval. + # So we should send it if ANY of enable, speed, brightness changed. + if force or self.enabled != enable or self.speed != speed or self.brightness != brightness: + opt_pkt = self._get_options_packet(enable, speed, brightness) + logger.debug(f"Setting Slash Options: En={enable}, Spd={speed}, Brt={brightness}") + if self.alt_mode: + self.dev.send_feature_report(opt_pkt) + else: + self.dev.write(opt_pkt) + + # 3. Enable/Disable packet (Command 0xD8) + if force or self.enabled != enable: + if enable: + if self.alt_mode: + self.dev.send_feature_report(SLASH_ENABLE_ALT) + else: + self.dev.write(SLASH_ENABLE) + else: + if self.alt_mode: + self.dev.send_feature_report(SLASH_DISABLE_ALT) + else: + self.dev.write(SLASH_DISABLE) + + # 4. Apply + if self.alt_mode: + self.dev.send_feature_report(SLASH_APPLY_ALT) + else: + self.dev.write(SLASH_APPLY) + + # Update state + self.enabled = enable + self.pattern = pattern + self.speed = speed + self.brightness = brightness + + except Exception as e: + logger.error(f"Failed to update Slash Lighting: {e}") + + # Compatibility method for old calls + def set_status(self, enable: bool): + # Default to current or default values if not set + p = self.pattern or "Flow" + s = self.speed or "Normal" + b = self.brightness or "Medium" + self.update(enable, p, s, b)