SteelSeries Aerox 3 Wireless (2.4 GHz) -- HID

Device: 1038:1838 (2.4 GHz receiver mode), USB 2.0 full speed, bcdDevice 1.31, firmware 1.3.1. The same silicon also enumerates as:

USB ID

mode

1038:1838

Aerox 3 Wireless, 2.4 GHz receiver (this driver)

1038:1878

Aerox 3 Wireless CS2 Dragon Lore, 2.4 GHz (protocol-identical)

1038:183A

Aerox 3 Wireless, USB wired mode (different opcodes)

1038:187A

Aerox 3 Wireless CS2 Dragon Lore, wired mode

This is the first wireless device supported by alloyctl: on top of the usual mouse configuration it reports a battery gauge. Findings below were obtained by reverse engineering on real hardware (hidraw probing, HID report-descriptor analysis) and cross-checked against rivalcfg and the community capture notes at gort818/aerox3-wireless.

The wireless flag

The receiver speaks the same 64-byte vendor protocol as recent wired SteelSeries mice, but every configuration opcode carries the ``0x40`` bit relative to its wired value. Verified on hardware: the wired color command 0x21 and wired battery query 0x92 are silent, while their flagged forms 0x61 and 0xD2 are acknowledged. Throughout this page the wired opcode is noted next to the wireless one.

The firmware-version query 0x90 is the exception -- it is not flagged.

USB interfaces

The receiver exposes five HID interfaces:

iface

hidraw

usage page

purpose

0

input0

0x01

mouse input (8 buttons, 16-bit X/Y, wheel, AC pan)

1

input1

0x01

keyboard (NKRO) for keyboard button bindings

2

input2

0x0C

consumer control (media-key bindings)

3

input3

0xFFC0

configuration: 64 B in/out, 512 B feature

4

input4

0xFFC1

vendor events: 64 B input only

All configuration traffic goes through interface 3 using HID output reports. The device does not use numbered reports: a raw write() on the hidraw node is a 0x00 report-number byte followed by the 64-byte payload (65 bytes total). None of the interfaces carry an interrupt OUT endpoint, so the kernel delivers the writes as control SET_REPORT transfers.

Command acknowledgement

Every recognized command is acknowledged on the interrupt IN endpoint of interface 3 with a 64-byte packet that echoes the command byte (e.g. 0x61 ... yields 61 00 ...). Unrecognized commands produce no response, which makes the ACK a reliable "command accepted" signal.

The whole 0x60--0x6F block echoes, so an ACK there confirms only that the opcode is inside the recognized range, not that each byte drives a distinct feature. This is why isolating 0x68 (High-Efficiency Mode) took a Windows GG capture rather than blind probing -- the ACK alone could not tell it apart from the unused opcodes in the block.

Commands (output reports, interface 3)

0x6D -- DPI presets (wired 0x2D)

0x6D <count> <active> <w1> [<w2> ...]
  • count -- number of presets configured (1--5)

  • active -- 0-based index of the active preset (same encoding as the 0xAD notification below)

  • wN -- one sensor byte per preset (single value, no separate X/Y)

DPI range 100--18000 in steps of 100. The sensor is a TrueMove Air; DPI does not map linearly to the wire byte, so the driver carries the exact table (100 -> 0x00, 400 -> 0x04, 800 -> 0x09, 1600 -> 0x12, 18000 -> 0xD6). Captured example, five levels 400/800/1200/2400/3200 with the first active:

6d 05 00 04 09 0d 1b 26

The presets are onboard CPI levels; colors, effects, polling and button bindings are a single global configuration shared by every level.

0x6B -- polling rate (wired 0x2B)

0x6B <rate>

Hz

byte

1000

0x00

500

0x01

250

0x02

125

0x03

Note the encoding differs from the Rival 3 family (where 1000 Hz is 0x01).

0x61 -- zone color (wired 0x21)

0x61 0x01 <zone> <R> <G> <B>

One write per zone. zone is 0x00 (strip top), 0x01 (middle) or 0x02 (bottom); the 0x01 sub-byte is constant across every capture. Writing a static color clears the rainbow on that zone. Captured:

61 01 00 ff 00 00      ; top zone solid red

There is no global "all zones" color command -- each zone is addressed individually, unlike the Rival 3 Gen 2 masked 0x21.

LED colors are not persisted. They survive neither the 0x51 save nor a sleep: whenever the mouse idles and the receiver re-enumerates (which it does frequently, every few seconds under some conditions) the zones go dark and must be re-sent by the host. This is a deliberate weight/battery trade-off on this device; the alloyctl live-preview re-applies lighting on every change.

0x62 -- rainbow effect (wired 0x22)

0x62 <mask>

mask enrolls zones into the rainbow cycle (bit0 top, bit1 middle, bit2 bottom; 0xFF = all, which is what rivalcfg and the GG captures use). Verified on hardware: with the engine on, 0x62 0xFF cycles all three zones, and a subsequent 0x61 static write on one zone freezes just that zone.

Like the Rival 3 Gen 2, the mask only works while the rainbow engine is on; the engine is the rainbow byte of 0x67 (below), so alloyctl sends 0x67 first in every lighting apply.

0x63 -- illumination: brightness + smart mode + dim timer (wired 0x23)

A single unified command carries three illumination settings:

0x63 <level> 0x01 <smart> 0x00 <dim0> <dim1> <dim2>
  • level -- brightness, a 4-bit value 0x00--0x0F (16 steps); higher values clamp to 0x0F. SteelSeries GG maps its 0--100 % slider onto this range, and so does alloyctl. Hardware-verified: 0x63 0x00 blanks the LEDs, 0x63 0x08 is a clear mid dim, 0x63 0x0F is full.

  • smart -- Illumination Smart Mode toggle (0x00 off, 0x01 on): the firmware blanks the LEDs while the mouse is moving and restores them when it is stationary, to save battery.

  • dim0..2 -- dim-timer idle timeout, 3-byte little-endian milliseconds (0--1200 s, 0 = off); the LEDs dim after this idle time.

alloyctl drives all three: level from the brightness slider, smart from the illumination smart-mode toggle, and the dim timer from its idle-seconds setting (clamped to the 1200 s ceiling, sent as little-endian ms).

0x66 -- reactive color (wired 0x26)

0x66 <enable> 0x00 <R> <G> <B>

enable is 0x01 to arm the click flash, 0x00 (with a zero color) to disable it. The enable byte is mandatory. The color is not persisted by 0x51 and resets on sleep, so the host re-arms it on every apply.

0x67 -- default lighting / rainbow master (wired 0x27)

0x67 <rainbow> <reactive>   ; each 0x00 or 0x01

The rainbow byte is the live master switch of the rainbow engine (a 0x62 mask sent while it is off is ignored), so alloyctl derives it as "any rainbow zone OR the startup choice wants rainbow" and sends 0x67 first in every lighting apply -- the same engine ordering as the Rival 3 Gen 2.

0x68 -- High-Efficiency Mode (wired 0x28)

0x68 <enable>   ; 0x01 on, 0x00 off

The GG "High-Efficiency Mode" battery saver ("extend battery life with automatic settings"). Reverse engineered from a Windows GG USB capture: toggling the mode does not send a single self-contained opcode -- GG emits a three-command bundle, in this order, and the 0x68 flag is only one part of it. Captured, mode turned on:

68 01                     ; High-Efficiency flag on
63 00 01 01 00 00 00 00   ; illumination level forced to 0 (LEDs blanked)
6b 03                     ; polling forced to 125 Hz

and turned off again, the two forced registers are restored to the user's values (here full brightness and 1000 Hz):

68 00                     ; flag off
63 0f 01 01 00 ...        ; brightness restored
6b 00                     ; 1000 Hz restored

So the mode is the bundle: the firmware does not drop polling or lighting on its own from the flag, the host drives the saver. alloyctl mirrors the order exactly -- flag, then LEDs off / brightness restore, then polling.

Toggling drops the 2.4 GHz link. All three commands ACK while the link is still up, then the mouse re-negotiates power and the link idles for about a second (the config interface answers 40 ff throughout) before it re-links with a 0xBC 0x01 wake. This is inherent -- the official GG build disconnects the same way -- not a bug. GG, and alloyctl, therefore wait for the ``0xBC 0x01`` re-link before the next command: sending into the idle window would burn the whole per-command wake-retry budget and stall. In particular GG defers its 0x51 save until after the re-link, and alloyctl blocks in the apply until the wake lands.

Because pushing 0x68 disconnects the mouse each time, alloyctl sends the bundle only when the toggle changes, never as part of the blanket save/revert re-push; a later 0x51 save simply commits the live state, which already carries the mode (and, while the mode is on, its forced 125 Hz / LEDs-off registers rather than the user's values). The dedicated flag 0x68 is what the earlier probing could not isolate (the whole 0x60--0x6F block ACKs), now pinned by the capture.

0x69 -- sleep timer (wired 0x29, wireless only)

0x69 <t0> <t1> <t2>

Idle time before the mouse sleeps, as 3-byte little-endian milliseconds, 0--20 minutes (0 = never). This is the "Battery Saver" stepper in GG. Captured: 5 min = 300000 ms:

69 e0 93 04            ; 0x000493E0 little-endian

Driven by alloyctl through ops->apply_sleep, cfg->sleep_min minutes converted to the little-endian ms count (0 = never), clamped to the 20 min ceiling.

0x6A -- button mapping (wired 0x2A)

0x6A <field1[5]> <field2[5]> ... <field8[5]>

Eight 5-byte fields at fixed offsets, identical in layout to the Rival 3 Gen 2:

offset

control

default action

0x00

Button 1

left click (0x01)

0x05

Button 2

right click (0x02)

0x0A

Button 3

middle (0x03)

0x0F

Button 4

back (0x04)

0x14

Button 5

forward (0x05)

0x19

Button 6

DPI toggle (0x30)

0x1E

Scroll up

0x31

0x23

Scroll down

0x32

First byte of each field selects the action: 0x00 disabled, 0x01--0x06 mouse button N, 0x30 DPI cycle, 0x31/0x32 scroll up/down, 0x51 <keycode> keyboard key, 0x61 <code> multimedia key.

0x51 0x00 -- save to onboard flash (wired 0x11)

Commits the live configuration to persistent memory. As noted above, LED colors are excluded from what the save persists.

0x90 -- firmware version

0x90

Answers with a bare ASCII string from byte 0 (no command echo, unlike the Rival 3 Gen 2 which prefixes 0x90), NUL-terminated and followed by a 4-byte build id, e.g.:

31 2e 33 2e 31 00 7d 4e f2 79      ; "1.3.1"

0xD2 -- battery level (wired 0x92)

0xD2

Answers 0xD2 <b>:

  • charging -- b & 0x80

  • level % -- ((b & 0x7F) - 1) * 5 (level nibble 1..21 -> 0..100 %)

Captured d2 14 -> 0x14 = 20 -> 95 %, not charging. When no mouse is linked to the receiver, the query does not answer with a charge but with the idle marker (see below), which the driver treats as "no reading".

Unsolicited events (interface 4)

The vendor event interface (0xFFC1, 64-byte input only) streams device-initiated notifications.

0xAD -- CPI level switch

0xAD <count> <active> <w1> ... <wN>

Emitted every time the active CPI level changes, including physical CPI-button presses. active is 0-based, the wire bytes repeat the level table in the 0x6D encoding. Captured with two levels 400/1600 toggling:

ad 02 00 04 12
ad 02 01 04 12

alloyctl listens here to keep the ACTIVE level indicator in sync with the hardware button, exactly as on the Rival 3 Gen 2.

0xBC -- power state notification

0xBC <state>   ; 0x01 wake / link, 0x00 sleep / unlink

Emitted when the linked mouse changes power state: 0xBC 0x01 when it wakes or (re)links to the receiver, 0xBC 0x00 when it sleeps or drops off. Captured across a 30 s sleep-timer cycle: the mouse idled out (bc 00), config queries answered the 40 ff idle marker for the whole nap, then a nudge woke it (bc 01). The wake edge is what prompts the host to re-push the non-persistent lighting, which the live-preview reapply already does.

0x12 -- unsolicited battery level

0x12 <b>

A device-initiated battery report, same byte encoding as the 0xD2 query (bit7 charging, ((b & 0x7F) - 1) * 5 = level %). Pushed right after a 0xBC 0x01 wake. Captured 12 13 -> 0x13 = 90 %. alloyctl does not need to key off it -- the UI polls 0xD2 on a timer -- but it confirms the gauge without a round-trip.

0x80 -- device-name marker

At the sleep boundary the interface also emits a one-off 0x80 report carrying the ASCII device name (captured 80 "Wireless Device"). It conveys no configuration and alloyctl ignores it; noted here so it is not mistaken for a command echo.

40 ff -- receiver idle marker

When the mouse is asleep or not linked, config-interface queries answer with a report beginning 40 ff ... instead of the expected echo. The transport skips this marker and re-sends the command to wake the link (see Waking the link above); once the retries are exhausted the battery op keys off the marker to report "no reading" rather than a bogus 0 %.

Bluetooth mode

The Aerox 3 Wireless also pairs over Bluetooth. This is selected by a physical switch on the underside of the mouse, not by a host command -- there is no HID opcode to toggle it, and over Bluetooth the mouse enumerates as a plain BT HID device (bustype 0x05, product 0x183A, a single standard-HID interface) with no ``0xFFC0`` vendor configuration interface. Consequently none of the commands on this page are reachable over Bluetooth, and alloyctl -- which drives the USB receiver -- does not manage the mouse in that mode.

SteelSeries GG shows a note that in Bluetooth mode angle snapping, acceleration/deceleration, illumination and button bindings (except keyboard and mouse-button bindings) revert to defaults, and the polling rate is forced to 125 Hz. This is not a bug: Bluetooth HID has far less bandwidth and a tighter power budget than the 2.4 GHz link, so the firmware drops the bandwidth-hungry and battery-hungry features and caps the poll rate to preserve battery.

USB wired (cable) mode

Plugging the mouse in by cable does not route it through this driver. On the cable the mouse enumerates directly as 1038:183A -- the same USB product id it uses over Bluetooth, not the 1038:1838 receiver id this driver binds to. alloyctl therefore does not detect the cabled Aerox at all, and none of the wireless configuration (including the DEVICE/POWER sections of the TUI) is available in that mode:

  • Detection -- 0x183A is not a registered driver (it appears in the tree only as bt_product_id, used to light the Bluetooth indicator), so device enumeration skips it.

  • Protocol -- this driver is built entirely around the receiver's wireless protocol (every opcode OR 0x40, config on interface 3, battery via 0xD2). The wired 0x183A endpoint speaks the un-flagged wired opcode set on different interfaces; the flagged commands would go unacknowledged.

  • Semantics -- the POWER settings describe running off the battery. On the cable the mouse is bus-powered and charging, so the sleep timer is moot and the gauge reports charging rather than a discharge level.

Configuring the wireless features requires the 2.4 GHz receiver. Supporting the cabled mouse would be a separate wired-Aerox driver for 0x183A with its own opcode set and no ALLOY_CAP_BATTERY; it is out of scope here.

Read-back

No command reads the current configuration back from the device. The 512-byte feature report on interface 3 reads back all zeros (HIDIOCGFEATURE), and no read opcode was found. As on the other SteelSeries mice, the pre-session baseline used by REVERT is kept host-side, seeded with driver defaults on first run; the 0xAD event is push-only.

Acceleration / deceleration / angle snapping -- not firmware

As on every other supported mouse, these have no onboard command and are implemented host-side by the pointer-transform daemon; see Host-side pointer transform (accel / snap).

Resolved by the Windows GG capture

The two items previously open here were pinned from a SteelSeries GG USB capture on Windows (see the 0x68 command and the 0xBC / 0x12 events above):

  • High-Efficiency Mode is the 0x68 flag driven as a three-command bundle (0x68 + forced 125 Hz 0x6B + blanked 0x63). This is the opcode the earlier 0x60--0x6F probing could not discriminate.

  • The wake/sleep handshake is the 0xBC power notification (01 wake/link, 00 sleep/unlink), with an unsolicited 0x12 battery push after wake and a one-off 0x80 device-name marker at the sleep edge.

Pairing (dongle bind)

Binding a fresh mouse to the receiver (mouse switched OFF, then held CPI while flicking to 2.4 GHz) only completes while the host puts the receiver into a bind/listen mode over USB -- the mouse-side gesture alone is not enough. This is the host step SteelSeries GG performs on "connect a new device".

Captured from a USBPcap trace of that GG flow, the trigger is a sequence of three unflagged output reports on interface 3, in order:

3b 00 00 ... 00        (preamble 1, echoed as 3b 00 ...)
11 00 00 ... 00        (preamble 2, echoed as 11 00 ...)
01 00 00 ... 00        (bind trigger, not echoed)

None carry the 0x40 wireless flag the configuration opcodes use -- these talk to the receiver's own bind logic, not the mouse. The 0x3b and 0x11 preamble are acknowledged like any command (the receiver echoes them even with no mouse bound yet) and arm the bind. 0x01 is the trigger and is not echoed: the receiver drops the link, enters bind mode and re-enumerates a second or two later once a mouse binds to it. a3wl_pair replays all three -- 0x3b / 0x11 via the echo-synced command path (so each is processed before the next), 0x01 fire-and-forget. Sending 0x01 alone was not enough on hardware; the preamble is required.

The bind is confirmed out-of-band: after the receiver re-enumerates, a 0xbc query returns bc 01 ... (link up; bc 00 is link down) and the 0xD2 battery gauge reads a real level.

Open questions / not yet reverse engineered

Every configuration opcode and event this receiver exposes is now identified, driven and documented above, including dongle pairing (see Pairing (dongle bind)). One transport refinement is left:

  • The 0xBC wake/sleep notification is decoded (see 0xBC -- power state notification) and a 0xbc query confirms a pairing bind, but the config transport does not yet gate on it: it works around an idle link with a blind wake-retry (re-send until the echo arrives). Consuming the unsolicited 0xBC 0x01 wake edge would let the host know the mouse is awake and replace the retry loop with an event-driven flush.