Instructions for AI agents working on this project.
cd rt82display
source ../.venv/bin/activate # or use uv
rt82display upload <file.qgif>Symptom: Could not find keyboard interface (0x36B0) or LCD interface (0x1919) did not appear after init
Cause: The RT82 has TWO USB devices:
0x36B0:0x30A3- Always visible (keyboard)0x1919:0x1919- Only appears AFTER init commands sent to 0x36B0
Cross-OS note: On macOS, hid.enumerate() reports the usage_page field correctly (e.g. 0xFF60 for the keyboard interface). On Linux with the libusb backend (common default), usage_page is returned as 0 for all interfaces. The 0x36B0 device exposes three interfaces:
- IF=0: Keyboard (usage page 0x0001)
- IF=1: Raw HID (usage page 0xFF60) -- this is the one needed for init
- IF=2: Mouse/consumer (usage page 0x0001)
The CLI handles this by: (1) preferring the correct usage_page when available, (2) falling back to interface_number == 1 (the standard QMK raw HID interface), (3) falling back to the first match as last resort.
Additionally, USB re-enumeration is slower on Linux (~1-2 seconds) than macOS (~300ms). The CLI polls for the 0x1919 device with retries rather than a single fixed sleep.
Solution: The CLI should automatically handle this, but if it fails:
- Check devices are visible:
rt82display list- (Linux only) Install udev rules so non-root users can access the HID device:
sudo cp udev/99-rt82.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
sudo udevadm triggerThen unplug and replug the keyboard.
- Manually activate LCD interface:
import hid
import time
# Send init to 0x36B0 — prefer usage_page 0xFF60, then IF=1, then first
devices = list(hid.enumerate(0x36B0, 0x30A3))
target = None
for d in devices:
if d.get('usage_page') == 0xFF60:
target = d
break
if target is None:
for d in devices:
if d.get('interface_number') == 1:
target = d
break
if target is None and devices:
target = devices[0]
if target:
dev = hid.device()
dev.open_path(target['path'])
dev.set_nonblocking(True)
dev.write(bytes([0xAA, 0xE2] + [0]*62))
time.sleep(0.05)
for _ in range(5):
dev.write(bytes([0xAA, 0xE0] + [0]*62))
time.sleep(0.05)
dev.close()
# Poll for 0x1919 (may take 1-2s on Linux)
for _ in range(10):
time.sleep(0.3)
found = list(hid.enumerate(0x1919, 0x1919))
if found:
for d in found:
print(f"Found: IF={d.get('interface_number')} UP=0x{d.get('usage_page', 0):04X}")
break
else:
print("0x1919 did not appear")- If still not working: Unplug and replug the keyboard
Cause: Transfer started but didn't complete properly.
Solution:
- Unplug keyboard
- Wait 5 seconds
- Replug keyboard
- Try upload again
Cause: Data format issue
Possible fixes:
- Ensure using QGIF format (not raw RGB565)
- Check byte order (should be Little Endian)
- Verify dimensions are 240×135
Cause: QGIF format might not match what firmware expects
Debug steps:
- Capture a working QGIF from web tool using browser console
- Compare header bytes with generated QGIF
- Try the captured QGIF to verify protocol works
| Device | VID | PID | Usage Page | Interface | Purpose |
|---|---|---|---|---|---|
| Keyboard HID | 0x36B0 | 0x30A3 | 0x0001 | 0 | Standard keyboard |
| Raw HID | 0x36B0 | 0x30A3 | 0xFF60 | 1 | Init commands |
| Mouse/Consumer | 0x36B0 | 0x30A3 | 0x0001 | 2 | Media keys etc. |
| LCD | 0x1919 | 0x1919 | 0xFF | 0,1 | Data transfer |
Note: On Linux (libusb backend), usage_page is reported as 0x0000 for all interfaces. Use interface_number to distinguish them.
-
Init (on 0x36B0:0x30A3, IF=1, UP=0xFF60):
- Send
AA E2+AA E0×5 - Poll for 0x1919 device (appears in ~300ms on macOS, ~1-2s on Linux)
- On Linux,
usage_pagemay be 0; select interface byinterface_number == 1
- Send
-
Download Mode (on 0x1919:0x1919, UP=0xFF):
- Query commands (
AA 10,AA 17, etc.) - Trigger download:
AA E3 00 00 00 01 00 00 01 - Screen shows "Downloading"
- Query commands (
-
Data Transfer:
- Setup packets (
AA 15,AA 16,AA 18) - Data packets:
AA 19 [offset_L] [offset_H] 00 38 00 00 [56 bytes]
- Setup packets (
-
Finalize:
- Status:
AA 1C - End:
AA 1A
- Status:
- Compressed RLE format
- 32-byte header + frame blocks
- Generated by
rt82display.qgif.encode_qgif()
- Uncompressed 16-bit pixels
- May not work on all firmware versions
- Use
--rawflag
| File | Purpose |
|---|---|
cli.py |
Main CLI application |
qgif.py |
QGIF encoder |
hid_device.py |
USB HID communication |
protocol.py |
Packet builders |
udev/99-rt82.rules |
Linux udev rules for non-root HID access |
PROTOCOL.md |
Full protocol documentation |
QGIF.md |
QGIF format specification |
- Don't prepend Report ID - Just send 64-byte packets directly
- Two-step connection - Must init 0x36B0 before 0x1919 appears
- Correct interface on 0x36B0 - Init must go to IF=1 (raw HID, UP=0xFF60), not IF=0 (keyboard) or IF=2 (mouse). On Linux,
usage_pageis 0 so select byinterface_number == 1 - Poll for 0x1919 - USB re-enumeration takes ~1-2s on Linux; use a retry loop, not a fixed sleep
- Little Endian - All multi-byte values are LE
- 56-byte chunks - Data packets have 8-byte header + 56 data
- QGIF required - Raw RGB565 usually doesn't display correctly