Skip to content

Reverse Engineering the NEXUS

codingncaffeine edited this page Sep 8, 2026 · 1 revision

Reverse Engineering the NEXUS

The protocol was not worked out from scratch here. Several people had already published working implementations, and this project would have been far slower without them:

What this project added was verification against the device's own report descriptor and measurement of everything the implementations disagreed about or left unstated.

Verify against the descriptor, not against another implementation

The descriptor is at:

/sys/class/hidraw/hidrawN/device/report_descriptor

Reading it settled several things that would otherwise have been guesses, and it agrees with all three independent implementations exactly.

Two interfaces, only one of them real

  • Interface 0 is the device. Usage Page 0x0C, vendor usage 0xFF00.
  • Interface 1 is a boot-keyboard descriptor and does nothing. Ignore it.

Both appear as hidraw nodes, adjacent in numbering, which is a good way to open the wrong one.

Reports

Report Direction Size Purpose
2 Output 1023 B Image data
1 Input 511 B Touch
3 Feature 31 B Commands
40x0E Feature 31 B Read-only info block

That last row is worth stating plainly, because it is easy to assume a block of feature reports is a block of commands. Reports 4 through 0x0E are device information — geometry, firmware, identifiers. Writing to them does nothing useful.

Commands — feature report 3

03 01 <0-100>      brightness   (0 = backlight off)
03 04              blank
03 0D <1-3> <loop> play a built-in firmware animation
03 0F              stop the animation

The firmware animations matter more than they look. When no software is driving the panel, the device plays one on its own. Blanking on exit leaves a dead black strip on a keyboard, which reads as broken hardware — so the application hands the panel back to animation 1 when it stops, rather than clearing it.

Image upload — output report 2

One frame is 121 writes of exactly 1024 bytes.

Each write is an 8-byte header followed by 1016 bytes of pixel data:

02 05 <xx> <is_last> <blk_lo> <blk_hi> <len_lo> <len_hi>   then 1016 bytes BGRA

The arithmetic checks out exactly:

120 × 1016  +  1 × 960  =  122880  =  640 × 48 × 4

so the last block is short, and there is no padding anywhere.

Pixels are BGRA, blue first. Alpha is ignored by the panel. This is a convenient accident on Linux: SkiaSharp's Bgra8888 memory layout on a little-endian machine is byte0=B, byte1=G, byte2=R, byte3=A — exactly the wire order — so a rendered surface can be handed to the device with no channel swap at all.

One header byte is a don't-care

Header byte 2 is 0x40 in two implementations and 0x1F in the third, and the panel accepts both. Treat it as don't-care rather than copying one and assuming it is meaningful.

Touch — input report 1

01 02 21 ...
        byte 5   pressed / released
        bytes 6-7  X, little-endian, 0-639

There is no Y, because there is one row of sensors. See The Panel for the report rate and the dropped-report problem that a fast swipe causes.

HID backends disagree about feature-report layout

A portability note that will bite anyone moving code between stacks: HID libraries differ on whether the report ID is included in the buffer you pass to a feature-report call, and on whether the returned length counts it.

That means a command that works with one backend can silently write a byte offset by one with another — and the panel simply does nothing, which looks identical to "the command is wrong". Verify against a known-good command (brightness is the easiest, because the result is unmistakable) before trusting any new one.