NuPhy's Air100 V3 runs NuPhy's own firmware on a WCH CH58x. Its core is
QMK-derived (keycode numbering, eeconfig, magic keycodes, debounce, encoder
code are all recognisably QMK), but it is not stock QMK and the VIA endpoint is
absent. So no VIA JSON will
ever make usevia.app talk to it. NuPhy shipped a proprietary raw-HID protocol
and a so-so configurator instead.
This repo is a full reverse-engineering of that protocol, a toolkit that does what NuPhyIO won't, and a documented map of the firmware down to the flash protocol — enough to build custom firmware.
- Hyper on Caps Lock — and any modifier combination. The whole
0x0100–0x1FFFrange works; NuPhyIO never offers it. - Tap/hold (mod-tap) — e.g. Caps = tap Esc, hold Hyper (
0x2F29), handy for Vim. The firmware supports QMK mod-tap; NuPhyIO just never exposes it. - Per-key RGB — NuPhy built it (
0xD8+ a hidden lighting mode) and never exposed it.nuphykit keycolor 255,0,0 W A S Dlights those keys red. - Layer locking —
TG(n). The board isn't momentary-only; NuPhy just never ships a toggle keycode. Layer chaining works 3 deep. - Backup/restore of everything. The board keeps settings in five separate
places; tools that read only the keymap silently lose the other four.
lightandfuncare kept per Mac/Win mode;backupsaves both, whichever way the switch is set. - Writes to both Mac and Windows banks, so a remap survives the physical Mac/Win switch (which selects the base bank live).
Install the nuphykit command straight from GitHub (needs hidapi, pulled in
automatically):
uv tool install git+https://github.com/gig3m/nuphykit # or: pipx install git+https://…
nuphykit showOr run from a checkout with no install at all:
uv run --with hidapi python -m nuphykit showThe examples below use python -m nuphykit; the installed nuphykit command is
identical.
Linux: the keyboard's HID nodes are root-only by default. One udev rule
gives the desktop user access (no sudo needed afterwards); plug the keyboard in
by cable, since the 2.4 GHz dongle doesn't carry the configuration interface (it
answers every command itself, PROTOCOL §83). The cable works with the board
switched to 2.4G too, which is how diag watches the wireless link:
echo 'KERNEL=="hidraw*", SUBSYSTEM=="hidraw", ATTRS{idVendor}=="19f5", MODE="0660", TAG+="uaccess"' \
| sudo tee /etc/udev/rules.d/60-nuphy.rules
sudo udevadm control --reload && sudo udevadm triggerQuit NuPhyIO isn't required, but note that any CLI command orphans the app's session until you reload it (see Hazards).
python -m nuphykit show # all five spaces + lighting
python -m nuphykit backup mybackup # everything
python -m nuphykit verify mybackup
python -m nuphykit restore mybackup
python -m nuphykit layout # decode the whole keymap with legends
python -m nuphykit hyper-caps # Caps = Hyper, both banks
python -m nuphykit hyper-caps --tap-esc # Caps = tap Esc / hold Hyper
python -m nuphykit key CAPS KC_A --both # by legend + keycode name
python -m nuphykit key r3c0 0x0F00 --both # or by row/col, or raw slot number
python -m nuphykit light --effect 6 --backlight 50
python -m nuphykit keycolor 255,0,0 W A S D --clear # PER-KEY colour
python -m nuphykit cfg func 1 1 # disable Win key
python -m nuphykit commands # NuPhy's own command names
python -m nuphykit log # live firmware debug log: radio, pairing, RSSI| space | get/set | size | granularity |
|---|---|---|---|
config |
0xB2/0xB3 |
0x1C00 |
whole 16-bit words (a 1-byte write writes 2) |
func |
0xE1/0xE2 |
4 per Mac/Win mode | single byte (never past byte 3) |
sleep |
0xF3/0xF5 |
4 (6 internally) | whole record only |
appdefine |
0xFB/0xFC |
0x03BA |
single byte; last 0x14 alias Mac light/func |
light |
0xD5/0xD6 |
17 per Mac/Win mode | single byte |
Payload byte 3 picks the mode for func/light (PROTOCOL §84).
A factory reset clears all five. A firmware flash preserves all five.
All from static analysis of the (freely downloadable) firmware image — no teardown:
- Command dispatch table — 42 commands, incl. 3 the app never sends
(
0xD8per-key RGB,0xC4zero-fills both macro buffers,0xF4RAM-only auto-sleep toggle). - Matrix pin map —
matrix_pins.json(rowsPB16/17/18/20/21/22, 18 cols). - LED driver — 2× AW20216S-class over SPI; per-LED channel map in
led_map.json. - Flash protocol — reimplemented and verified byte-for-byte vs NuPhyIO
(
nuphykit/bootloader.py). - CORRECTED 2026-09-29 [T2]:
PA5/PA6are the knob's rotary-encoder lines (QMK encoder table at0x3FC84), not the Mac/Win switch. The Mac/Win switch isPB9and the cable/wireless switchPB8(PROTOCOL §84). The tap-dance code does have a hold path (register on timeout, unregister on key-up; 100 ms floor on the timing field), so the one observed Tap Dance long-press that did not hold is not explained by the firmware code — cause unlocated (PROTOCOL §73, §79). QMK mod-tap works, tap and hold (§22). 0xE3/0xE5/0xE6(debounce, touch-bar) are no-op acks in 1.0.6.6 — 38 functional handlers plus the0xEEhandshake (§76).
The bootloader does no image validation. A one-byte edit to NuPhy's own 1.0.6.6 image, flashed with the code here, boots and reports the change over USB (the serial string). All five config spaces survived byte-identical.
Recovery is buildable-in: because nothing validates the image and you flash the
whole app partition, keeping the stock USB + 0xEF recovery code intact in every
build means 0xEF-reflash always works — no devboard needed for disciplined
development. Only a build that fails to even enumerate USB needs WCH ROM ISP
(a BOOT pad inside the case). See PROTOCOL.md §67–§69, §81.
VIA specifically is reachable by replacing the firmware with the community
CH58x QMK port (rgoulter/qmk_port_ch5xx, which supports VIA) — the matrix pins
and LED map here are most of that board definition. ZMK is ruled out (Zephyr has
no CH58x support). See PROTOCOL.md §70–§71.
- The Ctrl↔Caps swap is QMK's
keymap_configflag, almost certainly set by pressing0x7000during testing (PROTOCOL §57/§84). No command reads it; bind0x7001and press it once to clear it. - Readback proves storage, never behaviour. The keymap stores any 16-bit
value without validating it;
0x5600/0x56F1store and do nothing. (0x7000looked inert too — it wasn't; see above.) - CH582 vs CH583 is unconfirmed — the
0x82inGetBaseis a hardcoded constant, not a chip-ID read. Needs eyes on the PCB (§80). - Nothing has been compiled from source; modifying firmware code (vs the descriptor byte) is untested.
- Never sweep opcodes. A "benign" payload is a valid write, and
0xEFenters the bootloader. - Config writes must be whole 16-bit words — a 1-byte write clobbers the next byte, at even addresses too.
- Never hold a capture only in page memory — the page reloads when the device re-enumerates.
- Power cycling does not exit the bootloader; only a reflash does (NuPhyIO recovers it automatically).
- Any CLI command orphans NuPhyIO's session — its writes are ack'd and discarded until you reload the app.
- Host-side remappers corrupt results. Raycast's Hyper Key on Caps Lock
silently inverted a test result for four rounds. On Linux, read keypresses
from
/dev/hidraw*instead — below every remapper (the dongle's nodes in 2.4G mode).
uv run --with hidapi python tests/test_pure.pyHardware-free. Covers address arithmetic, slot/keycode resolution, the lighting byte map, and the flash frame builder checked against real captured frames.
| file | contents |
|---|---|
docs/BENCH-NOTES.md |
the maintainer's bench log: state of the test board, hazards, protocol on a page |
PROTOCOL.md |
the spec, every claim tagged by evidence tier |
AUDIT.md |
evidence rules and what is not proven |
SWEEP.md |
the UI-option sweep and its results |
firmware/README.md |
how to obtain the firmware (not included here) |
Claims are tagged T1 (physical behaviour observed), T2 (readback through a different channel), T3 (device ack — never counts), T4 (inference). Several confident claims turned out wrong mid-project; the audit records which and why, and the corrections are made in place. That evidence discipline is the point as much as the results.
The firmware binary and NuPhy's web-app bundle are not in this repo — they're
NuPhy's copyrighted work and aren't ours to redistribute. The firmware is
published by NuPhy over a public API; firmware/README.md shows how to fetch and
verify your own copy. Everything derived from it here (pin maps, channel maps,
the command table, the protocol) is factual information extracted for
interoperability.
This is independent reverse-engineering for interoperability and repair. It is
not affiliated with or endorsed by NuPhy. Flashing custom firmware or writing
undocumented commands can brick a keyboard; there is no warranty (see LICENSE).
MIT — free to use, modify, and distribute.
Reverse engineering is essentially complete for the application protocol, and the firmware is mapped down to the flash protocol. This is not a polished product — it is a correct one, with its gaps written down.
