Control the RGB rings around the joysticks on the ASUS ROG Ally and ROG Ally X over USB HID — no Armoury Crate, no ASUS services, no GUI.
The ROG Ally slowly pulses its joystick RGB rings whenever it sleeps on a charger, and there is no hardware switch to stop it. In a dark room at 3 AM, that strobe is relentless. The only official fix lives inside Armoury Crate — a 500 MB+ Windows-only stack of services, DLLs and COM objects that re-spawn each other and that you cannot run on SteamOS or Bazzite at all.
This is a single Python file. It talks to the Ally's microcontroller directly through USB HID feature reports, the setting is written into the MCU's own flash, and it survives reboots and even OS reinstalls. The HID protocol was decoded from the open-source asusctl project (Rust).
- One flag kills the charging strobe — the reason this project exists (
--no-sleep-strobe). - Settings persist in MCU flash across reboots and across OS reinstalls. Wipe Windows, install Linux — the byte you wrote is still there.
- No Armoury Crate, no
LightingService, no GUI. One Python file plushidapi. Nothing to install, nothing running in the background. - Four independent power-state bits — boot, awake, sleep, shutdown — each toggleable.
- 12 LED effects with per-call color, speed and direction: static, breathe, rainbow cycle/wave, stars, rain, laser, ripple, pulse, comet, flash and more.
- Supports both handhelds — ROG Ally (RC71L, PID
0x1ABE) and ROG Ally X (RC72L, PID0x1B4C). - Cross-platform via
hidapi— runs on Windows, SteamOS and Bazzite.
Prerequisites
- Python 3.8+
- A ROG Ally (RC71L) or ROG Ally X (RC72L), connected over USB
- The
hidapiPython binding (provides thehidmodule)
Install the dependency
pip install hidapiOn Windows, run from an admin shell if Armoury Crate's LightingService is still installed — it can overwrite your changes (see Service conflict).
Minimal — stop the charging strobe right now:
python ally_led_control.py --no-sleep-strobeExpected output:
Power: boot=1 awake=1 sleep=0 shutdown=1
byte=0x0B (00001011) sent=17
Turn the rings fully off / fully on:
python ally_led_control.py --all-off
python ally_led_control.py --all-onSet a raw power byte (bitmask, see Power byte):
python ally_led_control.py --power 0x0BSet an effect — name, RGB triple, speed, direction:
python ally_led_control.py --effect rainbow_wave --color 255,0,128
python ally_led_control.py --effect static --color 0,255,0
python ally_led_control.py --effect breathe --speed low
python ally_led_control.py --effect comet --color 0,128,255 --speed high --direction leftExpected output:
Effect: mode=0x3 color=(255,0,128) speed=med dir=right
effect sent=17 apply sent=17
List detected ASUS HID devices (useful for debugging enumeration):
python ally_led_control.py --listEverything is driven by CLI flags — there is no config file and no environment variables.
| Flag | Default | Description |
|---|---|---|
--no-sleep-strobe |
— | boot=1 awake=1 sleep=0 shutdown=1 → byte 0x0B. Disables the charging/sleep strobe. |
--all-on |
— | All four power bits set → byte 0x0F. |
--all-off |
— | All four power bits cleared → byte 0x00. |
--power BYTE |
— | Set the power state as a hex byte, e.g. 0x0B. |
--effect MODE |
— | Apply an LED effect (see Effects). |
--color R,G,B |
255,0,0 |
RGB color for the effect, each channel 0–255. |
--speed |
med |
Effect speed: low | med | high. |
--direction |
right |
Effect direction: left | right. |
--list |
— | Print every ASUS HID device matching VID 0x0B05. |
The MCU stores four power-state bits in its own flash. The byte is boot | (awake << 1) | (sleep << 2) | (shutdown << 3):
| Bit | Value | Name | What it controls |
|---|---|---|---|
| 0 | 0x01 |
boot |
LEDs on during system boot |
| 1 | 0x02 |
awake |
LEDs on while the system is active |
| 2 | 0x04 |
sleep |
LEDs on during sleep / charging — the strobe |
| 3 | 0x08 |
shutdown |
LEDs on during shutdown |
Common values: 0x0F = everything on, 0x00 = everything off, 0x0B = everything except the sleep strobe.
Speeds map to low=0xE1, med=0xEB, high=0xF5; directions to left=0x01, right=0x00.
The Ally's MCU is a USB composite device. The LED control lives in one specific HID collection, which the script locates by usage rather than by interface number:
- VID:
0x0B05(ASUS) - PID:
0x1ABE(Ally) or0x1B4C(Ally X) - HID collection: UsagePage
0xFF31, Usage0x0079(Collection 03) - Report ID:
0x5D
All commands are 17-byte HID feature reports led by the report ID.
Power: 5D D1 09 01 <power> 00 00 ... (17 bytes)
Effect: 5D B3 00 <mode> <R> <G> <B> <speed> <dir> 00 <R> <G> <B> 00 00 00 00
Apply: 5D B4 00 00 ... (17 bytes)
Effects are a two-step write: 0xB3 stages the effect, then 0xB4 commits it.
sequenceDiagram
participant U as ally_led_control.py
participant H as hidapi (USB HID)
participant M as ROG Ally MCU (Col03)
U->>H: enumerate(VID 0x0B05, PID 0x1ABE / 0x1B4C)
H-->>U: path of UsagePage 0xFF31 / Usage 0x0079
Note over U,M: Power state
U->>M: feature report 0x5D · cmd 0xD1 [power byte]
M->>M: write MCU flash
Note over U,M: Effect
U->>M: feature report 0x5D · cmd 0xB3 [effect]
U->>M: feature report 0x5D · cmd 0xB4 [apply]
On Windows, Armoury Crate's LightingService.exe re-applies its own config on startup and will overwrite direct MCU writes. To make a setting stick:
- Stop the service.
net stop LightingService
- Send the command.
python ally_led_control.py --no-sleep-strobe
- Update or remove
C:\ProgramData\ASUS\RogAura30\AuraSync.iniso Armoury Crate re-applies your preference on the next reboot — or disable the service entirely.
Armoury Crate's UI exposes separate AC and battery toggles for the awake state. Those are software-only: the MCU has a single awake bit. Armoury Crate stores two preferences in AuraSync.ini and re-sends the right one when the power source changes. This script writes the hardware bit directly.
-
Mapped the USB device — the MCU is a composite device with six HID collections across three interfaces; LED control is Collection 03.
-
Tried Frida hooks on
HidD_SetFeature,DeviceIoControlandWriteFileinLightingService.exeandArmouryCrateSE.Service.exe— zero HID calls captured, because on Windows the control path runs through ACPI/WMI, not HID. -
Analyzed the DLLs —
AuraSdk_x86.dllis a COM client;ServiceCore.dllimportsDeviceIoControlandGetFirmwareEnvironmentVariableW, confirming the WMI/ACPI path. -
Found the Rosetta Stone — the
asusctlRust project had already decoded the full protocol.rog-aura/src/keyboard/power.rsgives the exact power-byte encoding;rog-aura/src/builtin_modes.rsgives the effect packet format.PowerZones::Ally => vec![ 0x5d, 0xd1, 0x09, 0x01, (self.boot as u32) | ((self.awake as u32) << 1) | ((self.sleep as u32) << 2) | ((self.shutdown as u32) << 3), ]
Single-file project — open an issue or PR against ally_led_control.py. The effect table and packet format are the most useful places to extend (new modes, brightness, per-joystick zones).
This repository does not include a LICENSE file. The author has stated the intent is MIT ("the protocol is a fact, not IP"); add a LICENSE file to formalize redistribution terms.
- asusctl — Linux system daemon for ASUS computers; the source of the decoded protocol.
- allycenter — Decky plugin for Ally controls on SteamOS.
