DEV Community

Emir Mrkaljević
Emir Mrkaljević

Posted on

Naming what a USB device reports back: rpm, flags and modes from the vendor app's screen

In the first article I showed how usb-rea diffs USB captures per labelled click and works out what a Windows-only gadget's commands look like. That gets you a driver that can send. A useful driver also has to read: fan speed, temperature, whether the pump is running, which profile is active.

Since v0.4.0 usb-rea pairs every host query with the reply the device sends back and decodes the parts it can explain: constants, echoed command ids, sequence numbers, checksums. What's left are "readings": bytes that change on their own. usb-rea could say "bytes 2-3 are a 16-bit little-endian number between 794 and 4012", but not what that number is. This post is about the next step: giving those bytes names.

The trick: write down what the app shows

The vendor app already knows what every byte means; it displays it. So while you capture, you note what the screen says now and then:

usb-rea mark "seen rpm 1180 temp 34"
usb-rea mark "seen pump on, alarm off"
usb-rea mark "seen mode turbo"
Enter fullscreen mode Exit fullscreen mode

A seen marker is an observation, not an action. It doesn't end the action you're labelling and it's never diffed. usb-rea takes the reply just before the marker (what the app was showing when you typed it) or just after it, within one second, and looks for reply bytes that explain every observation of that name.

Numbers: identity or a linear fit

For numbers there are two checks:

  • identity: the raw value equals what the app showed every time (2+ observations, within one unit or 1%, because apps round);
  • linear: raw = value * scale + bias fits exactly and survives a leave-one-out check (3+ observations). That's the same guard the command inference uses, so two lucky points don't become a formula.

Candidates are the 8-bit readings, neighbouring bytes read as u16 LE/BE, and four bytes (or three plus a 0x00 high byte, for counters that haven't reached 2^24 yet) as u32 LE/BE. Some real outputs from the test captures:

u8 temp = (raw - 40) / 2, seen 80..86
u32be energy = raw / 1000, seen 70000..4653000
i8 temp = raw, seen -12..10
Enter fullscreen mode Exit fullscreen mode

The last one is new on main: when the app shows a negative value (seen temp -5), 8- and 16-bit readings whose top bit actually gets set are also tried as two's complement.

On/off flags

seen pump on and seen pump off count as 1 and 0 (true/false and yes/no work too). A name seen in both states becomes a status flag: the single bit of a reply byte that is 1 every time the app said on and 0 every time it said off. Several flags can share a byte. If two bits always flip together, usb-rea can't tell which one is the pump, so it leaves both unnamed and says so in a note.

Modes

Fan profiles, RGB effects, DPI presets: the app usually shows a word, not a number. A seen value that is a word is now kept as text:

usb-rea mark "seen mode silent"
usb-rea mark "seen profile gaming"
usb-rea mark "seen mode turbo, rpm 3900"   # a word goes last or before a comma
Enter fullscreen mode Exit fullscreen mode

A name seen as two or more different words, in three or more observations, is matched to the reply byte whose raw value is the same every time the app showed one word and different between words. If other bits of that byte change on their own (or it already holds named flags), usb-rea tries only the contiguous bits that differ between the words, e.g. bits[4:6] profile.

The synthetic fan, end to end

There's no hardware in this demo. It's a synthetic fan controller whose status reply ends with a mode byte, and the capture's markers note what its "app" showed, e.g. seen rpm 1207 temp 37 mode auto. This is the real output from current main:

$ usb-rea demo --device fan
...
device replies:
fan          6/6  0xa5 | = query byte 2 (0x10) | = query byte 1 | u16le round(value * 1 + 0) | sum8(bytes[0:5])
color        6/6  0xa5 | = query byte 2 (0x20) | = query byte 1 | 0x00 | 0x00 | sum8(bytes[0:5])
mode         6/6  0xa5 | = query byte 2 (0x30) | = query byte 1 | = query byte 3 | 0x00 | sum8(bytes[0:5])
poll 1       7/7  0xa5 | = query byte 2 (0x40) | u16le rpm = raw, seen 794..4012 | u8 temp = raw, seen 35..38 | mode: 0x00 = auto, 0x11 = silent, 0x22 = balanced, 0x47 = turbo | sum8(bytes[0:6])

21 of 21 'seen' observations matched a reply byte (mode, rpm, temp)
Enter fullscreen mode Exit fullscreen mode

All 7 bytes of the status reply are explained now. The report backs each name with the frames it came from:

| App showed    | Marker at | Raw  | Reply frame |
|---------------|-----------|------|-------------|
| mode auto     | 1.115 s   | 0x00 | 22          |
| mode silent   | 11.616 s  | 0x11 | 164         |
| mode balanced | 13.112 s  | 0x22 | 186         |
| mode turbo    | 14.621 s  | 0x47 | 204         |
Enter fullscreen mode Exit fullscreen mode

(shortened; the full table also lists the repeated auto observations.) The generated driver's query_poll_1() returns the names. Here's what it gives for a fake reply with mode byte 0x47:

{'raw': 'a5 40 9c 04 22 47 ee', 'ok': True, 'rpm': 1180, 'temp': 34, 'mode': 'turbo', 'checksum_ok': True}
Enter fullscreen mode Exit fullscreen mode

A mode value it never saw comes back as the raw number instead of a made-up label.

Honest limits

  • Only replies get names, not commands (commands already get their names from your action labels).
  • A name that fits more than one place is left unnamed, with a note telling you to observe again once the candidates differ. usb-rea would rather say nothing than guess.
  • A mode is one byte, or one contiguous bit group inside it. Only the words you actually observed get labels.
  • Signed means i8/i16 only; 32-bit readings are never treated as signed.
  • If the app rounds harder than one unit (RPM in steps of 10), only the identity check copes, not a scaled fit.
  • seen markers need timestamps, so they're skipped in the marker-free --labels mode.
  • It still hasn't been run on a physical gadget. Everything above comes from synthetic captures, packets rebuilt from liquidctl/rivalcfg protocol code, and public Wireshark captures.

Try it

pip install -U usb-rea   # 0.6.0 or newer
usb-rea demo --device fan
Enter fullscreen mode Exit fullscreen mode

It's also an MCP server (usb-rea mcp, listed in the official MCP Registry) if you'd rather have an agent drive the capture session while you click. Sending anything to hardware still needs explicit human confirmation.

If you have a Windows-only USB gadget, a capture with a few seen markers would help more than anything else right now. Open an issue: https://github.com/emirmrkaljevic/usb-rea

I built usb-rea with help from AI coding tools; the inference itself is deterministic and every claim in its reports points at captured frames.

Top comments (0)