Skip to content

Keyboard: SteelSeries Apex support (per-key RGB, OLED, 24 scenes) - #14

Merged
fbnlrz merged 4 commits into
mainfrom
dev/1.0.12
Aug 1, 2026
Merged

Keyboard: SteelSeries Apex support (per-key RGB, OLED, 24 scenes)#14
fbnlrz merged 4 commits into
mainfrom
dev/1.0.12

Conversation

@fbnlrz

@fbnlrz fbnlrz commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Adds a Keyboard tab: per-key RGB, the 128x40 OLED, brightness and the settings
the firmware exposes, for the Apex Pro, 7, 5 and 9 families across all three
protocol generations, wired or over the dongle.

Measured, not assumed

Most of the protocol was worked out on an Apex Pro TKL Wireless (2023)
(1038:1632, firmware 3.24.1) over /dev/hidraw, because the existing sources
disagree and one of them is wrong for this device. Its vendor interface declares
three reports and probing confirmed each:

  • Configuration is a 64-byte output report, written as 65 bytes with a
    leading 0x00 report id.
  • Query replies arrive one command behind, so the input queue has to be
    drained before an answer can be trusted. 0x90 answers with bare ASCII
    (3.24.1); 0x92 answers 92 95, which is 95 % as two BCD digits — the same
    number the keyboard's own battery indicator was showing.
  • Bulk payloads are 641-byte feature reports with the command byte first.
    Prefixing a 0x00 report id instead — what OpenRGB and hidapi's convention
    produce — makes this device stall the control transfer, and 640, 644 or 64
    bytes are refused outright. Enough stalls in a row make the keyboard
    re-enumerate, which is how this was found.

Per-key lighting is proven rather than inferred: lighting exactly four scattered
keys (A, N, R, I) and leaving the other 108 dark came out on the board exactly
that way, which no zone-based control could produce. The OLED is proven the same
way — a full-panel test picture came back on the display. Its transport is eight
641-byte chunks under command 0x0C carrying row-major pixels.

What does not work is documented as not working

The actuation command 0x2D is accepted by the hardware and changes nothing on
firmware 3.24.1. Rapid Trigger, Rapid Tap/SOCD and Protection Mode have never
been captured by anyone — not OpenRGB, apex-tux, OmniLED or steelseriesgg-rs.
The tab says so, and carries a raw command probe so someone with hardware can
help find them.

Two behaviours that look like bugs

  • Direct mode is sticky. The board holds the last frame indefinitely, so the
    LEDs are handed back explicitly on quit and when the master toggle goes off.
  • The firmware composites its profile and battery over the top of the panel
    whatever is sent, so every screen starts 10 px down; it repaints on its own
    events, so frames are re-sent twice a second. Its screensaver never fires while
    Inari is pushing, so the blanking timer lives on this side.

Also in here

  • 24 themed scenes — Tokyo Night, Osaka Neon, Sakura, Kanagawa, Foxfire,
    Rain, Fuji Sunrise, Ripple, Aurora, Lantern, Amanogawa, Slash, Hanabi, Torii,
    Shibuya, Neon Rain, Glitch, Vaporwave, Koi, Komorebi, Taiko, Inferno, Plasma,
    Kaminari — written as maths over (x, y, phase) so one scene animates correctly
    across full-size, TKL and mini boards.
  • A to-scale, clickable key map (left-click paints, right-click erases) with the
    caps drawn as plastic lit from underneath.
  • ISO and ANSI layouts, an OLED transport picker with a test picture for boards
    whose packet shape is not yet confirmed, and a host-side screensaver.
  • Fixes a latent test bug this surfaced: the hidraw poll test built its pipe
    without O_CLOEXEC, so the new tests that shell out inherited its write end
    and kept the pipe alive past the close the test was measuring. It failed only
    alongside those tests — the sort of failure that gets written off as flaky.

442 Rust tests, 161 frontend tests, clippy -D warnings clean.

fbnlrz added 4 commits July 27, 2026 22:17
Adds a Keyboard tab: per-key RGB with host-rendered effects, the 128x40
OLED, brightness and the settings the firmware exposes, for the Apex Pro,
7, 5 and 9 families across all three protocol generations.

Most of the protocol was measured on an Apex Pro TKL Wireless (2023)
(1038:1632, firmware 3.24.1) rather than taken on trust, because the
existing sources disagree and one of them is wrong for this device. Its
vendor interface declares three reports and probing confirmed each:

  - Configuration is a 64-byte output report, written as 65 bytes with a
    leading 0x00 report id. Zone colour, brightness, apply and the
    queries all work this way.
  - Query replies arrive one command behind, so the input queue has to be
    drained before an answer can be trusted. 0x90 answers with bare ASCII
    ("3.24.1"); 0x92 answers 92 95, which is 95% as two BCD digits - the
    same number the keyboard's own battery indicator was showing.
  - Bulk payloads are 641-byte feature reports with the command byte
    FIRST. Prefixing a 0x00 report id instead - what OpenRGB and hidapi's
    convention produce - makes this device stall the control transfer,
    and 640, 644 or 64 bytes are refused outright. Enough stalls in a row
    make the keyboard re-enumerate, which is how this was found.

Per-key lighting is proven, not inferred: lighting exactly four scattered
keys (A, N, R, I) and leaving the other 108 dark came out on the board
exactly that way, which no zone-based control could produce. The OLED is
proven the same way - a full-panel test picture came back on the display.
Its transport is eight 641-byte chunks under command 0x0C carrying
row-major pixels; apex-tux and OmniLED use other combinations for other
models, so all of them are kept and the Display tab can send a test
picture with each until one lands.

Two behaviours are worth knowing because they look like bugs:

  - Direct mode is sticky. The board holds the last frame indefinitely,
    so the LEDs are handed back explicitly on quit and when the master
    toggle goes off.
  - The firmware composites its profile and battery over the top of the
    panel whatever is sent, so every screen starts 10 px down, and it
    repaints on its own events, so frames are re-sent twice a second.
    Its screensaver never fires while Inari is pushing, so the blanking
    timer lives on this side.

What does not work is documented as not working rather than shipped as if
it did: the actuation command 0x2D is accepted by the hardware and
changes nothing on firmware 3.24.1, and Rapid Trigger, Rapid Tap/SOCD and
Protection Mode have never been captured by anyone. The tab says so, and
carries a raw command probe so someone with hardware can help find them.

Also fixes a latent test bug this surfaced: the hidraw poll test built
its pipe without O_CLOEXEC, so the new tests that shell out (nvidia-smi,
playerctl) inherited its write end and kept the pipe alive past the close
the test was measuring. It failed only alongside those tests, which is
exactly the kind of failure that gets written off as flaky.

Framebuffer grows a runtime height so one set of drawing primitives
serves both the base station's 128x64 and the keyboard's 128x40.

432 Rust tests, 161 frontend tests, clippy -D warnings clean.
The effect list was the one every vendor ships - static, wave, breathing -
which is a waste of a board that can address 112 keys individually. Adds a
Scenes effect backed by twelve of them, each a small piece of
shader-style maths over the key's normalised position and a phase:

  Tokyo Night, Osaka Neon, Sakura, Kanagawa, Foxfire, Rain, Fuji Sunrise,
  Ripple, Aurora, Lantern, Amanogawa, Slash.

They are written as maths over (x, y, phase) rather than as per-layout
frames, so the same scene animates correctly across a full-size board, a
TKL and a mini without a table per model. Everything is pure and
deterministic - same inputs, same colour - which is what lets the UI
preview a scene with no keyboard attached and what makes them testable at
all. The per-key randomness is a hash of the key rather than a PRNG
stream, because a key that re-rolls its "random" brightness every frame
strobes instead of twinkling.

The details that make them read as scenes rather than as gradients are
deliberate: Osaka's neon has hard dropouts because a constant shimmer
just looks like noise; Kanagawa's crest curls with y because a straight
front is not a wave; the blade in Slash has a gap between strikes,
without which it is a wave with extra steps; and Sakura's petals sway, or
they fall like rain.

The key map was too small and too flat to show any of it. It now measures
its container and scales the board to fill it, and the caps are drawn as
dark plastic lit from underneath - glow scaled by how lit the key
actually is, a bevel, a highlight - instead of as flat swatches. That is
both what the hardware looks like and what makes a dim scene readable at
a glance.

442 Rust tests, 161 frontend tests, clippy -D warnings clean.
…e found

Doubles the scene list to 24: Hanabi, Torii, Shibuya, Neon Rain, Glitch,
Vaporwave, Koi, Komorebi, Taiko, Inferno, Plasma and Kaminari.

The new ones lean on the thing a per-key board can do that a zone board
cannot - structure that sits still while something moves through it.
Torii draws an actual gate in board space and lights what is behind it;
Shibuya crosses two streams of long thin streaks; Hanabi puts four shells
in flight on their own cycles, each a thin shock front with sparks
lagging behind; Kaminari builds its bolt row by row, each row offsetting
the last.

Several of them are deliberately dark most of the time - Kaminari is a
bolt and then nothing, Taiko is a hit and then silence - and that broke
two tests, which is the interesting part. Both sampled a scene at one or
two instants: one caught Kaminari mid-dark and called it flat, and the
other compared Taiko at 0.4 and 2.9, which land on exactly the same beat
because its period divides the gap. Sampling a whole cycle and asserting
the property that actually matters - it lights up and varies at some
point - fixes both without weakening either.

The scene picker was hidden behind the "Scenes" effect chip, so nobody
found it. It is its own always-visible card now, each tile carrying a
strip of its own palette so the list is scannable by eye.
@fbnlrz
fbnlrz merged commit 23a2c6f into main Aug 1, 2026
6 checks passed
@fbnlrz
fbnlrz deleted the dev/1.0.12 branch August 1, 2026 15:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant