❤️ Also a big thank to my LLM for helping me put my note sorted, with a title, and be able to create this big documentation
another thank to c00k1e for helping me to discover the chipset name/brand and gadgetoid for posting a photo of the second chipset under the keyboard
The ND104 keyboard appears as two separate USB HID devices:
| Device | Vendor ID | Product ID | Purpose |
|---|---|---|---|
| Keyboard | 28007 0x6d67 |
364 0x016c |
Key input, VIA configuration, RGB, macros, etc. |
| Screen | 21826 0x5542 |
1 0x0001 |
LCD display (weather, time, animations, custom images) |
It use custom chipset bought by the manufacturer U-BEST (http://you-best.com.tw/).
Data can be sent using the hidraw device.
- Find the correct hidraw node:
grep -i "5542" /sys/class/hidraw/hidraw*/device/uevent- Write the 32-byte payload (or 64-byte padded) to that device:
cat weather_payload.bin > /dev/hidrawXor in python:
with open('/dev/hidrawX', 'wb') as f:
f.write(payload + b'\x00'*32) # pad to 64 bytesThis section explains how to communicate with the ND104 keyboard's screen (the LCD display) via USB HID on Linux. It covers the device identification, report structure, payload format, checksum.
The screen is driven by the following chipset:
UB32F341S(LCD controller for screen 1.47" TFT 320 × 172 px, is an ARM chipset running Keil RTX V5.5.4 using LVGL for graphics) it can have 2 differents chipset:- GC9307+XL (SCHEME: https://gist.github.com/user-attachments/assets/2d186789-abe8-415b-a4c4-19d40c5d071a)
- ST7789 (rounded corner 1.47 inch eg: https://www.adafruit.com/product/5393)
UB25Q08A(real-time clock chip) is a DS1302Z
The ID Vendor is: 0x5542 and the ID Product is: 0001
| Feature | Raw Command (bytes) | Raw length | Wrapped | Notes |
|---|---|---|---|---|
| Weather update | 1c 02 00 00 00 0d 49 01 a5 fe+ temps + icon |
- | No (direct) | Contains temps + icon + Uses its own header and checksum |
| Time update | 1c 03 00 00 00 0f 00 00 a5 38 00 0a 00 01+ date/time + checksum |
11 | Yes (wrapped via me()) | Weekday is 0-6 (Sun=0) |
| Reset screen | 52 00 01 |
3 | Yes | Reboots the screen |
| Screen menu Down | 57 00 01 |
3 | Yes | OSD down |
| Screen menu Up | 57 00 02 |
3 | Yes | OSD up |
| Select / OK | 57 00 03 |
3 | Yes | OSD enter |
| Back / Return | 57 00 04 |
3 | Yes | OSD back |
| Theme / Colors | FD 01 + 5 bytes(bgH, bgL, fgH, fgL, themeID) |
7 | Yes | Colors are RGB565 big-endian + theme ID |
| Image upload | BA 06 AA B0+ size / data chunks |
- (Special) | No | Complex chunked protocol |
| Arbitrary HEX send | 88 + any hex bytes |
1 + N | Yes | Test mode only |
| Screen Flip / Switch | (not via screen device) | - | - | These are keycodes sent via the keyboard HID interface, not the screen. |
The firmware upgrade for the screen can be accessed by pressing Fn + F13 for few second
and a DTU interface will appear:
the DFU upgrade mode of the LCD screen exit after 3 minutes of no operation.
When you run sudo lsusb -v -d 5542:0001, the descriptor shows:
Interface Descriptor:
bInterfaceClass 3 Human Interface Device
bInterfaceSubClass 0
bInterfaceProtocol 0
HID Device Descriptor:
bcdHID 1.10
bNumDescriptors 1
bDescriptorType 34 Report
wDescriptorLength 34
The raw report descriptor (parsed) is:
06 60 FF 09 61 A1 01 09 62 15 00 26 FF 00 95 40 75 08 81 02 09 03 15 00 26 FF 00 95 40 75 08 91 02 C0
This tells us:
- Usage Page: Vendor-defined (
0xFF60) - Collection: Application
- Input report: 64 bytes (
0x40= 64) : this is what the screen sends to the host. - Output report: 64 bytes : this is what the host sends to the screen.
No Report ID is defined, All sendReport() calls use Report ID 0 : the first byte of the report is the command/opcode.
Even though the USB descriptor specifies a 64-byte output report, the screen's firmware only reads the first 32 bytes. The remaining 32 bytes are ignored (they can be any value, but typically zero-filled).
- 64-byte HID report: The complete packet that must be sent to the device. The USB HID layer expects exactly this size.
- 32-byte payload: The meaningful part of the report, which contains the actual data (weather, time, etc.).
When you write to /dev/hidraw, the kernel automatically pads your data to the report size (64 bytes) if you write less. Therefore, you can safely send a 32-byte array, the kernel will append 32 zeros.
The checksum is a single byte that validate the integrity of data packets.
It's used on a lot of software and other thing to prevents a corruption or a falsification.
Two types appear:
- 8-bit checksum : for weather (different algorithm) and for time (bitwise-NOT sum).
- 16-bit CRC : used in the generic wrapper (bytes 6-7) and also in the time packet.
Algorithms are detailed in the relevant sections below.
The payload format was reverse-engineered with different tools and method.
The header (0x1c 0x02 ...) are fixed magic + command signature, and the remaining bytes carry the actual data.
This is a direct 32-byte payload; no wrapper is used.
Offsets and Meaning
| Offset (hex) | Length | Description | Example |
|---|---|---|---|
0x00-0x09 |
10 | Fixed header | 1c 02 00 00 00 0d 49 01 a5 fe |
0x0A (10) |
1 | Reserved (always 0x00) | 00 |
0x0B (11) |
1 | Always 0x08 (maybe a flag) | 08 |
0x0C (12) |
1 | Reserved (always 0x00) | 00 |
0x0D (13) |
1 | Weather icon index (0-8) | 02 |
0x0E-0x0F |
2 | Current temperature (signed, °C × 10, big-endian) | 01 0e (270 --> 27.0°C) |
0x10-0x11 |
2 | Max temperature (same encoding) | 01 26 (294 --> 29.4°C) |
0x12-0x13 |
2 | Min temperature (same encoding) | 00 ed (237 --> 23.7°C) |
0x14 (20) |
1 | 8-bit checksum (see below) | d4 |
0x15-0x1F |
11 | Zero padding | 00 ... |
Temperatures
Temperatures are stored as signed 16-bit integers (big-endian) multiplied by 10.
For negative values, use two's complement (e.g., 0x8000 + abs(value)). The official code uses the formula:
If temperature is negative, store as `32768 + abs(temp)` (which is the same as signed 16-bit representation).
Example: -5.2°C --> -52 --> `0xFFCC` (big-endian: `FF CC`).
Weather Icon Index Mapping
| Index | Weather Condition |
|---|---|
| 0 | Sunny / Clear |
| 1 | Partly cloudy |
| 2 | Cloudy |
| 3 | Overcast |
| 4 | Rain / Drizzle |
| 5 | Snow / Sleet |
| 6 | Thunderstorm |
| 7 | Mist / Fog |
| 8 | (other / unknown) |
Weather Checksum Computation
The checksum ensures that the data sent to the screen is not corrupted. The screen will ignore the packet if the checksum is wrong.
Algorithm:
- Sum all bytes from offset
0x0Ato0x13(10 bytes). Allow the sum to overflow beyond 8 bits (we only care about the lowest 8 bits (i.e.,sum & 0xFF)). - Take the two's complement of that 8-bit sum. This is equivalent to
(0x01 - sum) & 0xFF. - Store this value at offset
0x14.
Why two's complement? Because when the screen re-computes the sum of the same bytes plus the checksum, the result will be exactly 0x01 (or 0x00? Actually it will be 0x01 if we use 0x01 - sum).
In practice:
sum_bytes = sum(payload[0x0A:0x14]) & 0xFF
payload[0x14] = (0x01 - sum_bytes) & 0xFFExample (Python)
import struct
def build_weather_payload(temp_c, max_temp_c, min_temp_c, icon_index):
payload = bytearray(32)
header = bytes.fromhex("1c 02 00 00 00 0d 49 01 a5 fe")
payload[0:10] = header
payload[0x0A] = 0x00
payload[0x0B] = 0x08
payload[0x0C] = 0x00
payload[0x0D] = icon_index
def store_temp(value, offset):
temp_int = int(round(value * 10))
packed = struct.pack('>h', temp_int) # signed 16-bit big-endian
payload[offset] = packed[0]
payload[offset+1] = packed[1]
store_temp(temp_c, 0x0E)
store_temp(max_temp_c, 0x10)
store_temp(min_temp_c, 0x12)
# Checksum
sum_bytes = sum(payload[0x0A:0x14]) & 0xFF
payload[0x14] = (0x01 - sum_bytes) & 0xFF
# rest already zero
return payloadThe payload structure is different from the weather one. Also no wrapper used, but note that the 16-bit CRC is computed for bytes 0x06-0x07.
Offsets and Meaning
| Offset (hex) | Length | Description |
|---|---|---|
0x00 |
1 | 0x1c |
0x01 |
1 | 0x03 |
0x02-0x04 |
3 | Reserved (0x00) |
0x05 |
1 | Command (0x0f) |
0x06-0x07 |
2 | 16-bit CRC (computed over the whole 32-byte payload, with checksum bytes temporarily 0) |
0x08 |
1 | 0xa5 |
0x09 |
1 | 0x38 |
0x0A |
1 | 0x00 |
0x0B |
1 | 0x0a |
0x0C |
1 | 0x00 |
0x0D |
1 | 0x01 |
0x0E-0x0F |
2 | Year (big-endian, e.g., 2026 -> 0x07 0xEA) Actually the code is using the full 16-bit for year. |
0x10 |
1 | Month (1-12) |
0x11 |
1 | Day (1-31) |
0x12 |
1 | Hour (0-23) |
0x13 |
1 | Minute (0-59) |
0x14 |
1 | Second (0-59) |
0x15 |
1 | Weekday (0-6, where 0 = Sunday) |
0x16 |
1 | 8-bit Checksum (see below) |
0x17-0x1F |
9 | Zero padding |
16-bit CRC (bytes 0x06-0x07)
It is a CRC-16/CCITT-FALSE with:
- Polynomial:
0x1021 - Initial:
0xFFFF - No final XOR
The CRC is computed over the entire 32-byte payload (with bytes 0x06-0x07 set to 0x00 temporarily) and stored in big-endian order.
def crc16_ccitt(data):
crc = 0xFFFF
for b in data:
crc ^= (b << 8)
for _ in range(8):
if crc & 0x8000:
crc = (crc << 1) ^ 0x1021 # CCITT polynomial
else:
crc <<= 1
crc &= 0xFFFF
return crcIn practice, the screen often accepts the time packet even if bytes 0x06-0x07 are left as 0x00. However, for full compatibility, you should compute the CRC.
Time 8-bit Checksum Computation (byte 0x16)
Algorithm:
- Sum all bytes from offset
0x09to0x15(13 bytes). Allow overflow. - Take the bitwise NOT (XOR with
0xFF) of the 8-bit sum. This is equivalent to(0xFF - sum) & 0xFF. - Store this value at offset
0x16.
Why bitwise NOT? Because when the screen adds up the same bytes plus the checksum, the result will be 0xFF (all ones), which can be checked.
In practice:
uint8_t sum = 0;
for (int i = 0x09; i <= 0x15; i++) sum += payload[i];
payload[0x16] = (0xFF - sum) & 0xFF; # or sum_bytes ^ 0xFFExample (Python)
import datetime
def build_time_payload():
now = datetime.datetime.now()
# Convert weekday: Python's weekday() Monday=0 --> getDay() Sunday=0
weekday_getday = (now.weekday() + 1) % 7
payload = bytearray(32)
payload[0] = 0x1c
payload[1] = 0x03
payload[2:5] = bytes([0,0,0])
payload[5] = 0x0f
payload[6:8] = bytes([0,0]) # will be overwritten by CRC
payload[8] = 0xa5
payload[9] = 0x38
payload[10] = 0x00
payload[11] = 0x0a
payload[12] = 0x00
payload[13] = 0x01
payload[14] = (now.year >> 8) & 0xFF
payload[15] = now.year & 0xFF
payload[16] = now.month
payload[17] = now.day
payload[18] = now.hour
payload[19] = now.minute
payload[20] = now.second
payload[21] = weekday_getday
# 8-bit checksum
sum8 = sum(payload[0x09:0x16]) & 0xFF
payload[0x16] = (0xFF - sum8) & 0xFF
# 16-bit CRC (set bytes 0x06-0x07 to 0 before computing)
crc = crc16_ccitt(payload)
payload[6] = (crc >> 8) & 0xFF
payload[7] = crc & 0xFF
return payloadThe screen can display custom images (PNG, JPG, or GIF) with a resolution of 320×172 pixels.
The upload uses a multi-packet chunked protocol with report header BA 06 AA B0.
This protocol is not wrapped in the generic 1C 02 wrapper : it is sent directly as 64-byte HID reports.
Important
- The screen expects the image to be exactly 320×172. If the dimensions are different, the image may be distorted or rejected.
- GIF files must have < 60 frames and size < 2 MB, the web interface extracts each frame and sends them individually (since GIFs are animated). The protocol for GIFs is more complex and involves sending frame delays.
- The chunk size of 60 bytes is derived from the web code (the packets are 64 bytes total, with 4 bytes header). You may experiment with larger chunks if the screen supports it, but 60 is safe.
Protocol Overview
Header Packet:
This packet announces the total size of the image data and starts the transfer.
| Offset | Length | Description |
|---|---|---|
0x00 |
4 | Fixed magic: BA 06 AA B0 |
0x04 |
4 | Total size of the image data (bytes) : big-endian |
0x08 |
56 | (Unused, set to 0) |
eg: For a 100-byte image, the first packet would start with BA 06 AA B0 00 00 00 64 followed by zeros.
Data Packets:
After the header, the image data is split into chunks and sent in subsequent packets.
Each packet has the same header BA 06 AA B0 followed by a chunk of the data.
| Offset | Length | Description |
|---|---|---|
0x00 |
4 | Fixed magic: BA 06 AA B0 |
0x04 |
60 | Data chunk (up to 60 bytes) : the rest is zero-padded |
(The report size is 64 bytes, so the chunk can be up to 60 bytes of data.)
- The last packet may contain fewer than 60 bytes; the remaining bytes are zero-filled.
- The screen expects the data in the same format as the original file (e.g., JPEG bytes, PNG bytes, or GIF frames).
Finalisation:
After all data packets have been sent, the screen is notified that the transfer is complete.
A final packet (or a separate command) may be required to apply the image.
In the web interface, after the image data transfer, the following sequence is sent (this appears to be a finalisation command).
15 0A 3F 01 01
followed by a reset of some state. This signals the screen to display the uploaded image.
Image Checksum/CRC Computation
You expect a checksum for this one ??? You can cry !!! There's no checksum ! xD
The screen relies on the correct number of bytes and the finalisation command to validate the transfer.
If the data is corrupted, the image will not display correctly.
Example (Python) : Sending a JPEG Image
The following example reads a JPEG file, splits it into chunks, and sends it to the screen.
def send_image(filepath):
# Read the image data
with open(filepath, 'rb') as f:
img_data = f.read()
total_size = len(img_data)
# 1. Send header packet
header = bytearray(64)
header[0:4] = bytes([0xBA, 0x06, 0xAA, 0xB0])
header[4:8] = total_size.to_bytes(4, 'big')
# (rest already zero)
with open('/dev/hidrawX', 'wb') as hid:
hid.write(header)
# 2. Send data packets
chunk_size = 60
for i in range(0, total_size, chunk_size):
packet = bytearray(64)
packet[0:4] = bytes([0xBA, 0x06, 0xAA, 0xB0])
chunk = img_data[i:i+chunk_size]
packet[4:4+len(chunk)] = chunk
hid.write(packet)
# 3. Finalisation command (optional but required)
final = bytearray(64)
final[0:4] = bytes([0x15, 0x0A, 0x3F, 0x01, 0x01]) # actual command from web code
hid.write(final)For commands like reset, navigation, theme, and debug, the raw command bytes must be wrapped in a "specific 32-byte format" before sending.
Wrapper Structure
The wrapper is built as follows:
| Offset | Length | Value / Description |
|---|---|---|
0x00 |
1 | 0x1c |
0x01 |
1 | 0x02 |
0x02-0x04 |
3 | 0x00 0x00 0x00 |
0x05 |
1 | Z + 5 where Z = number of bytes in the raw command (the length field) |
0x06-0x07 |
2 | 16-bit CRC over the entire 32-byte payload, with checksum bytes set to 0) |
0x08 |
1 | 0xa5 |
0x09 |
1 | Y[0] : First byte of raw command |
0x0A |
1 | 0x00 |
0x0B |
1 | Z (raw command length) |
0x0C |
1 | Y[1] : (if Z > 1) |
0x0D |
1 | Y[2] : (if Z > 2) |
| ... | ... | ... |
0x0C + Z - 1 |
1 | Y[Z-1] : Last byte of raw command |
0x0C + Z |
1 | 8-bit checksum = (0xFF - sum(0x09..0x1F)) & 0xFF |
0x0C + Z + 1 ... 0x1F |
- | Zero-padded to 32 bytes |
Y = This is the raw command (see the Feature Overview)
Z = The length field (number of bytes in the raw command)
Note
The 16-bit CRC (bytes 0x06-0x07) is the same as the one used for the time packet.
Compute it over the entire 32-byte > payload (with bytes 0x06-0x07 set to 0 and the 8-bit checksum set to 0 temporarily)
Example (Python) : Building the "Up" Command (57 00 02)
- Raw command: Y =
[0x57, 0x00, 0x02], soZ=3. - Length field at byte 5:
Z + 5 = 8--> hex:0x08. - The 32-byte payload (with placeholder for CRC and checksum):
| Offset: | 00 | 01 | 02 | 03 | 04 | 05 | 06 | 07 | 08 | 09 | 0A | 0B | 0C | 0D | 0E | 0F | ... |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| Value: | 1c | 02 | 00 | 00 | 00 | 08 (Z) |
00 (16bit CRC) |
00 (16bit CRC) |
a5 | 57 | 00 | 03 | 00 | 02 | 00(8b chk) |
00 | ... |
(with 0x06-0x07 set to 0 and 0x0E temporarily 0)
- Compute 16-bit CRC over the whole array with the information provided earlier
- Compute 8-bit checksum over bytes
0x09->0x1F(excluding0x0Einitially) and place the result at0x0E
#TODO UNFINISHED
#TODO
#TODO
Warning
This feature is primarily for debugging !
the screen may not respond or may behave unpredictably if you send invalid commands.
I discovered this debug option on the code, but I don't know how to use it/enable it...
It's only use is if you want send raw bytes to the screen...
The raw command for this feature is:
88 XX YY ZZ ...
where XX YY ZZ ... are the hex bytes you want to send.
Wrapping
The raw command is wrapped using the generic wrapper described in 4.4 Wrapped Command
The raw command length is Z = 1 + N, where N is the number of arbitrary hex bytes.
Checksum
The generic wrapper's checksum (8-bit and 16-bit) are used : no additional checksum is added for the 88 command itself.
Example
Suppose you want to send the raw bytes 01 02 03 to the screen for testing. The raw command becomes 88 01 02 03 (Z = 4).
You would then use the send_wrapped() function with this raw command.
raw = bytes([0x88, 0x01, 0x02, 0x03])
send_wrapped(raw)Note
These functions are not sent to the screen device; they are keyboard HID keycodes that the keyboard firmware interprets as screen control actions.
They can be sent as normal key press reports through the keyboard interface.
| Function | Keycode (decimal) | Hex |
|---|---|---|
| LCD_TG (toggle) | 32270 | 0x7E0E |
| LCD_UP | 32271 | 0x7E0F |
| LCD_DN | 32272 | 0x7E10 |
| LCD_RETURN | 32278 | 0x7E16 |
| LCD_ENTER | 32279 | 0x7E17 |
| LCDUP_TOGG | 32280 | 0x7E18 |
| LcdCalc | 32289 | 0x7E21 |
| LcdReset | 32290 | 0x7E22 |
To send these, you need to send a keyboard HID report (covered below in the Keyboard part).
This section covers the keyboard itself and all operations you can do with
The UB32VF105B chipset is a WCH (Nanjing Qinheng) CH585 (https://www.wch-ic.com/downloads/CH585DS1_PDF.html).
It's a multi-mode wireless MCU/SoC based on the QingKe V3C core, supporting BLE 5.0 and a high-performance custom 2.4GHz protocol using RISC-V (https://www.wch-ic.com/download/file?id=204 page 21)
The ID Vendor is: 6d67 and the ID Product is: 016c
The keyboard exposes 6 HID interfaces:
| Interface | Use |
|---|---|
| 0 | Standard keyboard (boot protocol) : handles key presses. |
| 1 | Vendor-specific (usage page 0xFF31, usage 0x0074) : used for VIA (dynamic keymap configuration) and keymap storage. |
| 2 | Vendor-specific (usage page 0xFF59) : RGB lighting control (based on usage page 0xFF59). |
| 3 | Vendor-specific : handle macro recording and other configuration. |
| 4 | Consumer control : handles media keys (play, volume, etc.). |
| 5 | Vendor-specific : firmware updates and debugging. |
Note
None of these interfaces are used for the screen : the screen is a completely separate device.
| Feature | Opcode (byte0) | Byte Layout (bytes 1‑5) | Notes |
|---|---|---|---|
| Set Keymap | 0x05 (5) |
[layer, row, col, keycode_high, keycode_low] |
layer = 0-5; row = 0-7; col = 0-15; keycode is a 16-bit USB HID usage ID (e.g. 0x04 = A). |
| Set Knob Action | 0x15 (21) |
[layer, 0, knob_index, keycode_high, keycode_low] |
knob_index = 0 : turn left/Counter-Clockwise)knob_index = 1 : turn right/Clockwise.Knob press is set via normal keymap (row 6, col 9). |
| RGB - Brightness | 0x07 (7) |
[3, 1, value, 0, 0] |
value = 0-255. Byte1=3 for keyboard RGB. |
| RGB - Effect Mode | 0x07 (7) |
[3, 2, effect_id, 0, 0] |
effect_id = 0-44 (see UI list for exact mapping). |
| RGB - Effect Speed | 0x07 (7) |
[3, 3, speed, 0, 0] |
speed = 0-255. |
| RGB - Color | 0x07 (7) |
[3, 4, hue, sat, 0] |
hue = 0-255, sat = 0-255 (converted from HSL/HSV). |
| Commit RGB | 0x09 (9) |
[3, 0, 0, 0, 0] |
Saves RGB settings to EEPROM. |
| Macro - Start Recording | 0x0D (13) |
None | Sending [13] starts recording key events. |
| Macro - Stop Recording | 0x10 (16) |
None | Sending [16] starts recording key events. |
| Macro - Upload Data | 0x0F (15) |
#TODO LATER - COMPLEX | Used to upload macro definitions after recording. |
| Debounce / USB Rate | 0x07 (7) |
[0, 4, sub_sub, value, 0] |
Byte1=0 for keyboard tools. sub_sub: 0=USB rate (1-8 KHz), 1=debounce speed (1-200 ms), 2=debounce mode (0=none,1=send-then-debounce,2=debounce-then-send). |
| Commit Debounce | 0x09 (9) |
[0, 0, 0, 0, 0] |
Saves debounce settings. |
| Enter DFU (Firmware Update) | 0x13 (19) |
[2, 0, 0, 0, 0, 48, 210] |
8-byte report. After sending, the device restarts in DFU mode. |
The keyboard is a fully QMK compatible device. To access at the firmware flashing mode you need to hold down both shift keys and press B
When the mode is enabled, the keyboard will not move the RGB anymore and keys will not repond.
the DFU upgrade mode of the keyboard exit after 3 minutes of no operation.
#TODO BIG TODO !!!