-
Notifications
You must be signed in to change notification settings - Fork 0
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:
- willneedit/NexusTool — C#/.NET, ships a udev rule and protocol notes
- fhuber83/ICueNexusPlusPlus — C++/hidapi, the cleanest library of the four
- bitfocus/companion-module-icue-nexus — TypeScript/node-hid
- aluferraz/inexus-osx — Swift/macOS, the most finished UX
What this project added was verification against the device's own report descriptor and measurement of everything the implementations disagreed about or left unstated.
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.
-
Interface 0 is the device. Usage Page
0x0C, vendor usage0xFF00. - 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.
| Report | Direction | Size | Purpose |
|---|---|---|---|
2 |
Output | 1023 B | Image data |
1 |
Input | 511 B | Touch |
3 |
Feature | 31 B | Commands |
4–0x0E
|
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.
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.
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.
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.
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.
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.