CH9350L UART Protocol Specification
Status: empirically verified by bidirectional bus sniffing. Frames in this document have been observed on the wire and the reference implementation (
ch9350_poc.py) reproduces them exactly, including byte-for-byte matching of the0x81Device Connection Frames. Coverage now includes power-on, attach, key/mouse forwarding, steady-state operation, runtime disconnect, target-side reattach with full sequence replay, and all four alternative dipswitch states (2/3/4) including0x80LED feedback, state-2 relative mouse, and state-3/4 absolute mouse.Manufacturer datasheet: WCH CH9350 V2.3 — wch-ic.com/downloads/CH9350DS_PDF.html. Section references in this document refer to that datasheet. See §Divergences from the datasheet for the places where on-the-wire behaviour differs from what the datasheet documents.
Reference implementation:
ch9350_poc.py(repo root) and the Gist at gist.github.com/sjmf/c4329fd27e403a264648bf4e7744655a
Overview
The CH9350L is a USB-to-UART bridge chip designed to operate in pairs:
- Lower Computer (LC) — acts as a USB host, enumerating attached HID devices (keyboard, mouse).
- Upper Computer (UC) — acts as a USB device, presenting HID interfaces to the target PC.
The two chips communicate over a full-duplex TTL UART bus. kvm-serial replaces the lower computer in software, speaking this protocol directly toward a physical UC module.
Each module has three relevant dipswitches: SEL selects the chip's role (SEL=1 → lower computer, SEL=0 → upper computer); S0 and S1 together select the working state (default 0/1 with handshake; alternatives 2/3/4 with fixed built-in descriptors — see §States 2/3/4); BAUD0/BAUD1 select the UART baud rate (default 115200).
Physical Layer
| Parameter | Value |
|---|---|
| Baud rate | 115200 |
| Data bits | 8 |
| Parity | None |
| Stop bits | 1 |
| Logic level | 3.3 V TTL |
Frame Structure
All frames share the same two-byte magic header:
The payload layout depends on CMD:
| CMD | Direction | Length | Used in | Payload format |
|---|---|---|---|---|
0x82 |
LC → UC | 4B total | 0/1 | Heartbeat: [IO] |
0x86 |
LC → UC | 3B total | 0/1, 2, 3, 4 | Device Notify: no payload |
0x80 |
LC → UC | 4B total | 0/1, 2, 3, 4 | Startup status (0xFF) / LED feedback (0x3N) |
0x89 |
LC → UC | 3B total | 0/1, 2, 3, 4 | Status announce: no payload |
0x81 |
LC → UC | variable | 0/1 only | [PORT] [LEN_LO LEN_HI] [DESCRIPTOR] [PID_LO PID_HI] [CHK] |
0x83, 0x88 |
LC → UC | length-prefixed | 0/1 only | [LEN] [SER] [report-bytes...] [CTR] [CTR_SUM] |
0x01, 0x02, 0x04 |
LC → UC | fixed (8 / 5 / 8B) | 2 / 2 / 3, 4 | State-2/3/4 keyboard / rel-mouse / abs-mouse |
0x10 |
LC → UC | 7B total | 2, 3, 4 | VID/PID modify: [VID_LO VID_HI] [PID_LO PID_HI] |
0x12 |
UC → LC | 11B total | 0/1, 2, 3, 4 | Keep-alive: [P1] [P2] [LED] [STATUS] [VERSION] |
Length-prefixed key/mouse frames (CMD 0x83 / 0x88)
- LEN — number of bytes following LEN, i.e.
SER(1) + report-bytes(n) + CTR(1) + CTR_SUM(1) - SER — labelling byte; encodes device class, protocol, and port number (see §Labelling Byte)
- CTR — monotonically increasing session counter, mod 256; separate per SER
- CTR_SUM — checksum:
(CTR + sum(report-bytes)) mod 256wherereport-bytesis everything between SER and CTR, exclusive
The CMD byte selects state 0 (0x88) vs state 1 (0x83); the payload format is identical between the two. See §State Machine for the transition rule.
Lower Computer → Upper Computer Frames
Heartbeat — CMD 0x82
Sent by the LC at ~1 s cadence when idle. During active key/mouse traffic the cadence becomes denser — heartbeats are interleaved with 0x83/0x88 frames, sometimes only ~50 ms apart. The ~1 s figure is the minimum-frequency idle baseline, not a strict period.
| Byte | Meaning |
|---|---|
IO |
High nibble = 0xA (fixed); low nibble = IO0/IO1/IO3/IO4 pin state. 0xA3 typical (all inputs high) |
Status Announce — CMD 0x89
No payload. In state 0/1 captures the LC emits 3 instances at ~2 s intervals starting ~1.4 s after the first 0x86, then stops for the rest of the session. In states 2/3/4 captures only a single 0x89 is observed, at startup. This is consistent with 0x89 being tied to the descriptor-announce phase: states 2/3/4 have no 0x81 descriptor exchange to wait on, so the opcode is sent once and not repeated. The UC continues normal operation in 0x89's absence. See §Divergences for what the datasheet does (and does not) say about this opcode.
Device Connection Frame — CMD 0x81
Sent by the LC at attach time, once per connected USB device. Carries the device's HID Report Descriptor; the UC uses these to construct matching HID descriptors that it will advertise to the target PC over USB. Without 0x81 frames in state 0/1 the LC's subsequent 0x83/0x88 input frames produce no effect on the target host — observed empirically. The likely mechanism is that the UC has no descriptor to advertise so its target-side endpoints either fail to enumerate or enumerate with mismatched report IDs; the LC has no way to detect this from its side.
| Field | Size | Notes |
|---|---|---|
| PORT | 1B | 0x00 = port 1 (DP/DM), 0x01 = port 2 (HP/HM) |
| LEN | 2B LE | length of DESCRIPTOR (74 / 165 bytes observed) |
| DESCRIPTOR | LEN bytes | raw USB HID Report Descriptor (no wrapping) |
| PID | 2B LE | device "PID" identifier — appears verbatim in the UC's 0x12 keep-alive once the descriptor has been processed |
| CHK | 1B | (sum(DESCRIPTOR) + sum(PID)) mod 256 |
Naming note: the datasheet calls the leading 1-byte field
IDand the trailing 2-byte field2-byte ID. Empirically the leading byte selects the USB port (0/1) and the trailing 2 bytes propagate into the UC keep-alive's PID-port1/PID-port2 fields, so this document refers to them asPORTandPID.
The descriptor is a standard USB HID Report Descriptor in the format defined by the USB-IF HID specification (Usage Page, Usage, Collection, Report ID, etc.). The captured mouse and keyboard descriptors used by the reference implementation are 74 and 165 bytes long respectively. The mouse descriptor contains a single Report ID item (0x01); the keyboard descriptor contains three (0x01 keyboard, 0x02 system control, 0x03 consumer control). In all observed 0x83/0x88 traffic the RID byte is 0x01; the keyboard's secondary report IDs (system control, consumer control) were not exercised. In states 3/4, 0x04 absolute-mouse frames likewise carry id=0x01 as a fixed prefix.
The LC retransmits the keyboard 0x81 frame ~2 s after its first transmission if the UC's 0x12 keep-alive has not yet reflected the keyboard PID. Whether the retransmit is purely time-driven or specifically gated on the missing ack is not pinned down — every observed retransmit was followed shortly by the matching ack, so the two are not separable from the available captures.
Keyboard — CMD 0x83 / 0x88
Two variants are produced depending on the USB device connected to the LC.
CH9329 / report-ID-prefixed keyboard, LEN = 0x0C
| Field | Value | Notes |
|---|---|---|
| LEN | 0x0C (12) |
|
| SER | 0x13 |
keyboard / HID / port 2 (see §Labelling Byte) |
| RID | report ID byte | 0x01 for the captured keyboard descriptor |
mod |
modifier byte | USB HID boot protocol modifier bitmask |
rsvd |
0x00 |
reserved per HID boot keyboard |
k0..k5 |
key scancodes | USB HID usage IDs, zero-padded |
Boot-protocol keyboard (no report ID), LEN = 0x0B
Produced by a real keyboard whose descriptor contains no Report ID item.
| Field | Value | Notes |
|---|---|---|
| LEN | 0x0B (11) |
one byte shorter — no report ID |
| SER | 0x11 |
keyboard / Unknown protocol / port 2 |
Modifier byte bitmask (same for both variants):
| Bit | Modifier |
|---|---|
| 0 | Left Ctrl |
| 1 | Left Shift |
| 2 | Left Alt |
| 3 | Left GUI / Win |
| 4 | Right Ctrl |
| 5 | Right Shift |
| 6 | Right Alt |
| 7 | Right GUI / Win |
Mouse — CMD 0x83 / 0x88
Three variants observed, distinguished by LEN and SER. The LEN=0x08 form is not in the datasheet; see §Divergences.
Absolute mouse with report ID — LEN = 0x0A
Wire format produced by a CH9329 bridge, which presents both relative (RID 0x04) and absolute (RID 0x05) mouse reports as part of its composite USB descriptor. Documented in datasheet §4.3 as a valid LC→UC frame.
| Field | Value | Notes |
|---|---|---|
| SER | 0x23 |
mouse / HID / port 2 |
| RID | 0x05 |
CH9329 absolute mouse report ID |
XL/XH |
X coordinate | 16-bit little-endian, raw USB HID absolute space |
YL/YH |
Y coordinate | 16-bit little-endian |
⚠ The CH9350L UC does not forward LEN=
0x0Amouse frames to the target host in state 0/1. Verified empirically: the LC sends valid0x83 0x0Aabsolute frames with correct framing, the UC's0x12keep-alive showsSTATUS=0x07(both ports enumerated), the target host's HID enumeration completes — and yet the cursor does not respond. Reproduced both with PoC-generated frames under several descriptor variants, and with a real CH9329 chip plugged into the LC's USB-host port as the source of absolute reports. See §Divergences for the full evidence.Implication: state 0/1 paired mode supports relative mouse only. For absolute cursor positioning on a CH9350L UC, configure the UC for state 3 or state 4 (dipswitch) where absolute coords flow as fixed-format
0x04frames and the UC owns the USB device descriptors directly. See §States 2/3/4.
Relative mouse with report ID — LEN = 0x08
Produced by a real mouse on port 1 whose descriptor contains a Report ID.
| Field | Value | Notes |
|---|---|---|
| SER | 0x22 |
mouse / HID / port 1 |
| RID | report ID | matches Report ID in the mouse's 0x81 descriptor (e.g. 0x01) |
dx/dy |
signed bytes | 8-bit relative deltas |
Relative mouse, boot protocol (no report ID) — LEN = 0x07
Produced by a real mouse whose descriptor contains no Report ID.
| Field | Value | Notes |
|---|---|---|
| SER | 0x20 |
mouse / Unknown protocol / port 1 |
CTR_SUM Worked Example
Keyboard frame 57 AB 83 0C 13 01 00 00 14 00 00 00 00 00 00 15:
- LEN=
0C, SER=13, report-bytes =01 00 00 14 00 00 00 00 00, CTR=00 CTR_SUM = (0x00 + (0x01+0x00+0x00+0x14+0x00+0x00+0x00+0x00+0x00)) mod 256 = 0x15✓
Device Notify — CMD 0x86
Emitted by the LC on USB device events. The same opcode is used for both attach and disconnect; what disambiguates is the follow-up traffic. See §Attach Sequence Timeline for the attach case and §Disconnect Sequence for the disconnect case. The datasheet (§4.6) names this "Device Disconnect Command" and is partially incorrect on both opcode reuse and the claimed UC reset; see §Divergences.
Status / LED — CMD 0x80
Single-byte payload, dual-purpose:
- Startup (
VAL=0xFF): sent twice ~210–260 ms apart immediately after the attach0x86. Means "LED state unknown" (pre-enumeration default). - State-2/3/4 LED feedback (
VAL=0x3N): during operation the LC emits0x80 (0x30 | LED_BITS)to mirror the target host's keyboard LED state back to the source keyboard. Low nibble matches the NumLk/CapsLk/ScrLk encoding of0x12'sLEDbyte (bit 0/1/2). Not observed in state 0/1 captures.
Upper Computer → Lower Computer Frames
Keep-alive / LED / PID-ack — CMD 0x12
Sent by the UC approximately every 1 second.
Total frame length: 11 bytes (header 2 + cmd 1 + payload 8).
| Field | Size | Notes |
|---|---|---|
P1 |
2B LE | PID of port 1 — populated from the PID field of the LC's 0x81 frame for port 1, once accepted |
P2 |
2B LE | PID of port 2 — populated similarly for port 2 |
LED |
1B | keyboard LED state: bit 0 = Num Lock, bit 1 = Caps Lock, bit 2 = Scroll Lock. 0xFF observed when target host hasn't reported LED state yet |
STATUS |
1B | bit-encoded UC health/enumeration state — see below |
VERSION |
2B | high byte 0xAC constant; low byte observed as 0x20 at steady state and 0x0B during transients (first frame after attach, frame after a STATUS change). Purpose not decoded |
STATUS byte interpretation (empirical):
| Bit | Mask | Meaning |
|---|---|---|
| 0 | 0x01 |
Port-0 device enumerated on target USB host |
| 1 | 0x02 |
Port-1 device enumerated on target USB host |
| 2 | 0x04 |
UART link healthy / UC alive |
Common values:
- 0x07 — both devices live on target, HID forwarding works (the only "all green" state)
- 0x04 — UART up, no target-side enumeration. Reasons include: cable in a DM/DP-less port, target replug pending re-enumeration (see §Reattach), board-mode dipswitch wrong, or no target host attached
- 0xFF — UC's "fully unknown" state. Observed at startup (one or two frames before the UC settles into 0x07 or 0x04), and during target-side cable yank
- 0x00 — pre-attach, before UC is fully up
The P1/P2 fields start at 00 00 after power-on and populate as the UC processes each 0x81 frame. State-1 entry is gated on PID-ack (matching P1/P2), not on receiving any 0x12. STATUS == 0x07 is the real "is HID actually being forwarded" indicator, not state-1 entry — a board can complete the UART-side handshake and reach state 1 yet have STATUS stuck at 0x04 indefinitely with no input reaching the target. See §State Machine for the transition rule and §Divergences for the comparison with the datasheet.
Diagnostic —
STATUSstuck at0x04. If the LC-side handshake completes (PIDs ack'd, state 1 entered) butSTATUSpersists at0x04andLEDpersists at0xFF, the protocol negotiation is healthy and the issue is downstream of the UART link. The most common cause is a single-DM/DP-port board where the target cable is plugged into the unwired port — the board used for these captures has two USB-A ports but only one wired DM/DP pair, and only the wired port reachesSTATUS=0x07. Confirmed by swapping ports: LC frames were byte-identical, butSTATUSnever moved off0x04on the wrong port. Identify the correct port empirically: the one whereSTATUSreaches0x07and the on-board LEDs mirror host NumLk/CapsLk/ScrLk state.
Attach Sequence Timeline
LC→UC and UC→LC frames from a bidirectional state-0/1 capture (gist), expressed as deltas from the first 0x86. Times are observational and will vary between runs.
t=0.000 LC → UC 57 AB 86 attach
t=0.260 LC → UC 57 AB 80 FF startup status (1/2)
t=0.470 LC → UC 57 AB 80 FF startup status (2/2)
t=0.470 LC → UC 57 AB 82 A3 heartbeat begins
t=0.500 UC → LC 57 AB 12 00 00 00 00 ... keep-alive (~30 ms after 2nd 0x80 FF; no PIDs)
t=1.440 LC → UC 57 AB 89 status announce (1/3)
t=1.490 LC → UC 57 AB 81 00 4A 00 [DESC] [PID] mouse Device Connection
t=1.550 LC → UC 57 AB 81 01 A5 00 [DESC] [PID] keyboard Device Connection
t=2.490 UC → LC 57 AB 12 40 00 00 00 ... keep-alive (mouse PID ack)
t=3.530 LC → UC 57 AB 89 status announce (2/3)
t=3.580 LC → UC 57 AB 81 01 A5 00 [DESC] [PID] keyboard Device Connection (retransmit)
t=4.520 UC → LC 57 AB 12 40 00 03 15 ... keep-alive (both PIDs ack) ← state 1
t=5.570 LC → UC 57 AB 89 status announce (3/3 — last observed)
t=... LC → UC 57 AB 83 [LEN] [SER] [...] key/mouse frames (state 1)
After both P1 and P2 in the UC's keep-alive match the PID values the LC sent in 0x81, the LC switches CMD from 0x88 (state 0) to 0x83 (state 1). All subsequent key/mouse frames use the paired form.
Disconnect Sequence
When a USB device is unplugged from the LC mid-session, only a single bare 0x86 is emitted; no other frames accompany it. Heartbeats continue at their normal cadence. From a capture with the mouse unplugged first and the keyboard ~2 s later:
... key/mouse frames, then idle ...
LC → UC 57 AB 82 A3 heartbeat (1 s cadence)
LC → UC 57 AB 86 ← mouse unplugged (no follow-up frames)
LC → UC 57 AB 82 A3 heartbeat (cadence unchanged)
LC → UC 57 AB 86 ← keyboard unplugged (no follow-up frames)
LC → UC 57 AB 82 A3 heartbeat (cadence unchanged)
... heartbeats only thereafter ...
The interval between the two 0x86 frames in this capture (~2 s) reflects the operator's actions, not any protocol timer. The UC's 0x12 keep-alive does not zero its P1/P2 PID slots after disconnect on the LC's USB-host side — it continues to report the last-known PIDs at its normal ~1 s cadence. The LC also remains in state 1 (CMD 0x83) and does not revert to state 0.
Re-attach on the UC's USB-device side (target replug)
When the UC's target-PC USB cable is unplugged and re-plugged (a different event from an LC-side peripheral disconnect), the UC's 0x12 does react: P1/P2 clear to 00 00 for one or two frames during the transient, STATUS flashes 0xFF, then PIDs settle back to their previous values but STATUS remains at 0x04 until the LC drives a re-enumeration.
To get STATUS back to 0x07 (i.e. devices re-enumerated on the target host), the LC must replay the full attach sequence (0x86 → 0x80 0xFF ×2 → 0x89 → 0x81 ×N). Retransmitting 0x81 alone is insufficient — the UC accepts the descriptors and reflects the PIDs in 0x12, but does not re-present its USB-device side to the target host. Validated by the PoC's _run_attach_sequence(wait_for_uc=False) reattach trigger.
State Machine
USB device attached on LC
│
▼
┌─────────┐ UC keep-alive shows ┌─────────┐
│ State 0 │ P1==mouse_pid AND │ State 1 │
│ SOLO │ ─────────────────────────────▶ │ PAIRED │
│ │ P2==kbd_pid │ │
└─────────┘ └─────────┘
CMD = 0x88 CMD = 0x83
State 0 (0x88) is the unpaired form; state 1 (0x83) is the paired form. The CMD byte switches; the frame payload is identical. Heartbeats run at ~1 s cadence in both states. Transition trigger: the UC's 0x12 keep-alive must reflect every PID the LC announced via 0x81 in its P1/P2 fields — receiving any single 0x12 is not sufficient. See §Divergences for how this differs from the datasheet's wording.
Capability note: state 0/1 supports relative mouse only. Although datasheet §4.3 documents the LEN=
0x0Aabsolute-mouse frame as a valid state-0/1 LC→UC frame, the UC's frame-forwarding firmware silently drops it. Verified against multiple descriptor variants and against a real CH9329 chip as the LC USB-host source. For absolute cursor positioning, use state 3 or 4. See §Mouse — CMD0x83/0x88and §Divergences.
Labelling Byte (SER)
SER (the byte after LEN in 0x83/0x88 frames) is a packed bitfield encoding device class, USB protocol mode, and port number. Per CH9350 datasheet §4.3:
| Bit | Meaning |
|---|---|
| 7, 6, 3 | Reserved |
| 5, 4 | Device class — 01 = keyboard, 10 = mouse, 11 = multimedia, 00 = other |
| 2, 1 | Protocol — 01 = HID, 10 = BIOS, 00 = Unknown, 11 = reserved |
| 0 | Port — 0 = port 1 (DP/DM), 1 = port 2 (HP/HM) |
Decoded examples of observed values:
| SER | Bits 5,4 | Bits 2,1 | Bit 0 | Decode |
|---|---|---|---|---|
0x11 |
01 (kbd) | 00 (unknown) | 1 (port 2) | Real boot keyboard, port 2 |
0x13 |
01 (kbd) | 01 (HID) | 1 (port 2) | HID keyboard, port 2 |
0x20 |
10 (mouse) | 00 (unknown) | 0 (port 1) | Real boot mouse, port 1 |
0x22 |
10 (mouse) | 01 (HID) | 0 (port 1) | HID mouse, port 1 |
0x23 |
10 (mouse) | 01 (HID) | 1 (port 2) | HID mouse, port 2 |
Empirically the UC accepts Unknown-protocol values (bits 2,1 = 00) and forwards them correctly, despite the datasheet implying only HID/BIOS are valid.
Interpretation (USB HID). These bits likely map to USB HID's two
SET_PROTOCOLmodes: -HID(01) → Report Protocol → frame carries anRIDbyte after SER (LEN =0x0Ckeyboard,0x0A/0x08mouse). -BIOS(10) → Boot Protocol → fixed-layout 8-byte keyboard / 3-byte mouse report, noRID. -Unknown(00) → device classified as neither (no Report ID item in its descriptor, no boot interface advertised); the UC handles it as fixed-layout, hence theRID-less LEN =0x0B/0x07variants.This is consistent with the data but has not been verified against a device that explicitly advertises HID Boot Protocol (
bInterfaceSubClass = 0x01).
States 2/3/4 (Alternative Dipswitch Configurations)
States 2, 3 and 4 are simpler modes that bypass the descriptor-exchange handshake: the UC presents fixed built-in HID descriptors to the target host, and the LC forwards HID reports as fixed-length frames. The three modes differ only in what built-in descriptor the UC advertises; the LC-side UART protocol is essentially identical between states 3 and 4. All three are selected via the S1/S0 dipswitches on both ends (SEL still selects LC vs UC role); BAUD pins still control baud rate independently:
| S1 | S0 | State | UC built-in HID interfaces (datasheet §3.3–3.5) |
|---|---|---|---|
| HIGH | HIGH | 0/1 (default) | None — descriptor sent over UART via 0x81 |
| HIGH | LOW | 2 | BIOS keyboard + relative mouse |
| LOW | HIGH | 3 | BIOS keyboard + absolute mouse |
| LOW | LOW | 4 | BIOS keyboard + HID Digitizers (replaces abs mouse for multi-monitor) |
The "BIOS keyboard" wording comes straight from the datasheet (§3.3 et seq.) and means a USB HID Boot-protocol keyboard — the kind a PC's BIOS/UEFI accepts before any OS-level HID drivers load. The mouse semantics, however, differ between the three modes, and that determines which mode is appropriate for which environment:
- State 2 — legacy / pre-boot. BIOS keyboard + relative mouse. This is the right choice for BIOS setup, boot menus, recovery consoles, and UEFI CSM environments, where the boot mouse protocol only understands relative motion. States 3 and 4 will not enumerate in those environments because their absolute-mouse / HID-Digitizers descriptors fall outside the boot protocol.
- State 3 — modern OS, no handshake, abs mouse. BIOS keyboard + absolute mouse. Useful when the descriptor-exchange handshake of state 0/1 isn't available (e.g. minimal embedded LC firmware) but absolute-cursor positioning is wanted. Most modern OSes (Linux, macOS, Windows) accept the abs-mouse interface.
- State 4 — HID Digitizer. The defining feature of state 4 is that the UC identifies on the target USB bus as a HID Digitizer (the same device class as a graphics tablet / pen input), not as a regular mouse. The chip exposes a different USB device class so the host OS routes pointer reports through its digitizer/pen pipeline rather than its mouse pipeline. The HID Digitizers class itself is widely supported (Linux
hid-multitouch, macOS, Android, iOS, Windows all parse the descriptors), but how the OS routes those reports is implementation-dependent. On Windows 7+, digitizer abs-coords map cleanly to the full virtual desktop spanning multiple monitors — solving the limitation that a plain abs-mouse only addresses the primary monitor. On Linux, macOS, and other targets, a digitizer report may land in a tablet/pen input pipeline rather than driving the system cursor, depending on the desktop environment and its input stack. The datasheet's §3.5 caveat — "some systems do not support HID Digitizers devices" — is best read as "OS routing of digitizer events to the cursor varies; verify on your target" rather than as a strict OS allowlist. State 3 (UC identifies as a regular HID Mouse with absolute coords) is the safer default for plain cursor-positioning KVM use.
LC → UC fixed-length frames
| CMD | Frame | Used in | Description |
|---|---|---|---|
0x01 |
57 AB 01 + 8-byte HID boot keyboard report |
2, 3, 4 | Keyboard |
0x02 |
57 AB 02 [btn] [dx] [dy] [wheel] |
2 only | Relative mouse |
0x04 |
57 AB 04 [id] [btn] [XL] [XH] [YL] [YH] [wheel] |
3, 4 | Absolute mouse — id=0x01, X/Y as 16-bit LE |
0x10 |
57 AB 10 [VID_LO] [VID_HI] [PID_LO] [PID_HI] |
2, 3, 4 | VID/PID modification (datasheet §3, p.9): override the UC's USB descriptor identity |
Verified empirically across all three modes by bidirectional sniff: state-2 gist, state-3/4 gist.
Common to all three modes (2, 3, 4):
- Startup announce. The LC emits
0x86 → 0x80 0xFF → 0x89 → 0x80 0xFFat attach, identical to state 0/1 but with no0x81Device Connection frames — the UC has built-in descriptors and does not need them. - UC keep-alive (
0x12) flows. Within ~250 ms of the startup announce, the UC begins emitting0x12keep-alives at ~1 s cadence andSTATUSreaches0x07.P1/P2stay at0000for the entire session (no descriptor announce, nothing to ack), confirming thatSTATUSbits are gated on USB-side enumeration on the target, not on PID-ack. 0x80 0x3NLED-feedback channel from LC → UC during operation, mirroring the host's keyboard LED state back to the source keyboard. Example: pressing CapsLk → LC emits01 00 00 39 ...→ UC responds with12 ... led=0x02→ LC echoes80 32; on release the LC drops back to80 30.- Per-keystroke retransmit. Each key event produces 3–4 repeated frames on the wire (no SER, no counter, no checksum, so the LC cannot detect loss — it just retransmits).
State 2 (relative mouse):
- Mouse
0x02format verified.57 AB 02 [btn] [dx] [dy] [wheel]with signed 8-bit dx/dy (two's complement). The LC emits up to ~7 frames per ~50 ms burst during continuous motion — higher rate than keyboard, with no retransmit padding (a dropped sample is masked by the next). - Idle keyboard reports during mouse activity. With both a keyboard and mouse plugged into the LC, continuous mouse motion produces occasional
57 AB 01 00 00 00 00 00 00 00 00frames (no key pressed) interleaved with mouse frames. They appear only during active mouse traffic — not during keyboard-only typing or steady-state idle. Most likely the LC's USB host poll cycle reads both endpoints together and emits the keyboard endpoint's "no change" report alongside each batch of mouse reports. - State 2 silently drops absolute mouse. Plugging a CH9329 (abs-mouse + keyboard composite) into the LC in state 2: keyboard reports forwarded via
0x01, but no0x04frames ever appeared on the wire and abs-mouse coordinates were silently dropped. The link was healthy throughout (STATUS=0x07); the LC simply has no destination for abs-mouse reports because the UC's state-2 built-in descriptor is relative-only. Switching to state 3 or 4 fixes this.
States 3 and 4 (absolute mouse):
- No
0x02frames on the wire — only0x04. State-2 relative-mouse and state-3/4 absolute-mouse modes are mutually exclusive. 0x04format verified.57 AB 04 0x01 [btn] [XL XH] [YL YH] [wheel]— 7-byte payload after the cmd byte;id=0x01is a fixed report-ID prefix; X/Y are 16-bit little-endian.- Effective X/Y range is 0..1023 (10-bit), not the 16-bit field width. The chip's built-in absolute-mouse descriptor declares an 11-bit signed field (
LogicalMin=-1024, LogicalMax=+1023, RepSize=11); bit 10 is the sign bit and bits 11+ are silently ignored. Sending values outside 0..1023 produces a periodic clip-sweep-clip-sweep pattern with period 2048 in the value: cursor sweeps left-to-right onnx ∈ 0..1023, clamps at the left edge fornx ∈ 1024..2047(negative-interpreted), sweeps again onnx ∈ 2048..3071(low 11 bits positive), and so on. Note: this is different from the CH9329, which uses a 12-bit (0..4095) range despite emitting the same0x04wire frame format — the chip's USB-side descriptor differs even though the UART-side framing matches. Calibrate empirically per board if the symptom recurs. - The LC integrates relative motion into absolute coordinates when the source device is a relative-motion mouse. So states 3/4 force absolute-mouse semantics on the target regardless of what the source device reports.
- States 3 and 4 are indistinguishable at the UART layer because the choice between them is made on the UC, not the LC. The UC reads its own dipswitch and selects which built-in descriptor to advertise to the target — HID Mouse for state 3, HID Digitizers for state 4 — without any signalling from the LC. The LC just emits
0x04abs-coordinate frames either way, and the UC routes them to whichever of its built-in descriptors is active. From an implementation perspective, states 3 and 4 share the same LC code path; the operator picks between them via dipswitches on the UC based on what the target host supports. - Sustained frame stream required for cursor motion. A real LC emits
0x04frames at ~50 ms intervals during continuous motion (typically hundreds of frames over a few seconds, with sub-pixel deltas rolled forward into each next frame). An emulator that fires one isolated0x04per command does not produce visible cursor movement on the target — even though the target's USB host controller still receives the report (the screen un-dims, indicating the OS sees activity). The likely mechanism is that HID Digitizers / abs-mouse drivers require a sustained stream to treat reports as active input rather than as glitches; isolated reports wake the bus but do not update the cursor. The PoC's REPLm X Yissues one frame per command and reproduces this "screen wakes, cursor doesn't move" symptom; an LC integration that wants S3/4 cursor motion must emit a continuous burst per move event, mimicking the real LC's poll-driven cadence.
Divergences from the datasheet
The CH9350 V2.3 datasheet is broadly accurate but in several places contradicts what a real CH9350L LC actually emits on the wire. The captures referenced below were all taken against a known-working hardware setup (real LC + USB keyboard + USB mouse, paired with a real UC that successfully forwarded HID input to a target PC).
0x86 is fired at both attach and disconnect, and the UC does not reset
- Datasheet (§4.6 "Device Disconnect Command"): "The lower computer will send the command when it detects the device is removed, and the upper computer will reset the chip when it receives the command."
- Observed (attach):
0x86is the first frame the LC emits after a USB device is plugged in, followed by0x80 0xFF×2, heartbeats,0x89, and one0x81per device. - Observed (disconnect): the LC emits a bare
0x86per device unplugged, with no follow-up frames. The opcode is the same in both contexts; the presence or absence of subsequent0x80/0x89/0x81frames is what disambiguates. - No chip reset: after both devices are disconnected, the UC's
0x12keep-alive continues at its normal cadence with its previously-learned PIDs in theP1/P2slots. Whatever "reset" the datasheet refers to is internal to the UC and not visible from the LC side.
0x80 is used in every state, not only state 2/3/4
- Datasheet (§4.8 "Status Change Command"): "State 2/3/4 supports this command, which is sent by the lower computer, received by the upper computer and has response."
- Observed in state 0/1:
0x80 0xFFis sent twice (~210–260 ms apart) at attach time, immediately after0x86. The0xFFpayload means "LED state unknown" (pre-enumeration default). - Observed in states 2/3/4: the same
0x80 0xFFstartup pair, plus a recurring0x80 0xNNLED-feedback channel during operation (NN = 0x30 | LED_BITS). The datasheet's "Status Change Command" naming is consistent with this LED-feedback role; the datasheet is wrong that the opcode is exclusive to states 2/3/4.
0x89 is not defined in the datasheet at all
- Datasheet: no entry for
0x89. - Observed: sent 3 times at ~2 s intervals during the descriptor-announce phase in state 0/1, and once at startup in states 2/3/4 (where there is no descriptor announce). Not seen during steady-state operation, typing, mouse movement, or disconnect.
0x81 is sent at attach in state 0, not on "device property mismatch" in state 1
- Datasheet (§4.1 "Device Connection Frame"): "State 1 in lower computer mode will send the data frame when a device property mismatch is detected."
- Observed: the LC sends
0x81at attach time while still in state 0 (CMD0x88). The frame is not a recovery mechanism; it is the primary means by which the LC tells the UC what HID descriptors to advertise to the target PC. State-1 transition happens after the UC acknowledges the descriptor (see below).
0x81 field naming: leading byte = port, trailing 2 bytes = PID
- Datasheet (§4.1): describes the frame as
0x57 0xAB 0x81 [1-byte ID] [2-byte Payload length] [Payload] [2-byte ID] [1-byte parity check]. The twoIDfields are not given distinct names. - Observed: the leading 1-byte
IDselects the USB port (0x00= port 1 / DP-DM,0x01= port 2 / HP-HM). The trailing 2-byteIDis the device PID and is reflected verbatim into the UC's0x12keep-alive in the correspondingPID-port1/PID-port2slot once the descriptor is processed. This document calls themPORTandPIDfor clarity.
State-1 transition is PID-ack, not first 0x12
- Datasheet (§3.2): "When CH9350L is used in pairs, it switches from state 0 to state 1." — implies a single-event transition.
- Common prior assumption (in earlier versions of this document and in the PoC): receiving any
0x12from the UC is the trigger. - Observed: the UC sends
0x12keep-alives starting ~30 ms after the second0x80 0xFF, well before any0x81has been sent — and continues sending them with00 00PIDs until each0x81is processed. The LC does not transition to state 1 (CMD0x83) until the UC's0x12reflects every PID the LC has announced via0x81. This was proven by bidirectional capture: with two devices announced, the LC stayed in state 0 until bothP1andP2were populated in the UC's keep-alive. - Interpretation (USB HID): this is the natural ordering rule for a composite HID device — INPUT reports for any interface cannot be safely forwarded until all configured interfaces have completed enumeration on the target host. The PID-ack gate is the LC's mechanism for waiting on that.
Labelling byte: "Unknown" protocol bits are valid
- Datasheet (§4.3): documents bits 2,1 as
01 = HID, 10 = BIOS, 00 = Unknown, 11 = reserved, implying only HID/BIOS are usable. - Observed: SER values
0x11(kbd / Unknown / port 2) and0x20(mouse / Unknown / port 1) are routinely sent by a real LC for keyboards and mice whose USB descriptors lack aReport IDitem, and the UC forwards them correctly. The "Unknown" classification is a normal operating mode, not an error condition.
Mouse frame variant LEN = 0x08 not documented
- Datasheet (§4.3): documents only the LEN=
0x0A(CH9329 absolute, with report ID) and LEN=0x07(boot relative, no report ID) mouse frame formats. - Observed: a third variant exists — LEN=
0x08, SER=0x22, with aReport IDbyte preceding the 4-byte boot mouse data. This is what the LC emits when the connected USB mouse has a Report Descriptor that includes aReport IDitem but uses standard relative coordinates.
LEN = 0x0A absolute mouse frames are silently dropped by the UC in state 0/1
- Datasheet (§4.3): documents the LEN=
0x0A, RID=0x05absolute mouse frame as a normal LC→UC frame in state 0/1, alongside the relative variants. The descriptor-announce mechanism (0x81) is presented as a general-purpose channel for declaring arbitrary HID Report Descriptors to the UC. - Observed: the UC accepts the descriptor handshake (the announced descriptor's PID is reflected in
0x12'sP1/P2slot,STATUSreaches0x07, the target host enumerates the device with the expected HID interfaces). But absolute mouse reports never reach the target's input pipeline — the cursor does not move. The same UC accepts and forwards LEN=0x07/0x08relative-mouse frames and LEN=0x0B/0x0Ckeyboard frames immediately, against the same descriptor exchange. - Evidence (multiple sources):
- PoC-generated
0x83 0x0Aframes with several composite-mouse descriptor variants — two-Application TLCs, single Pointer Physical with two reports, with/withoutWheeldeclarations, with 0..65535 / 0..32767 / 0..4095 absolute coordinate ranges, with Report IDs0x02and0x05. Relative reports (RID0x01) moved the cursor in every variant; absolute reports (any RID, LEN=0x0A) produced no cursor motion at any coordinate, including screen edges. - PoC-generated relative frames using RID
0x04instead of RID0x01against a descriptor declaring both — to test whether the UC's frame validator is RID-pinned independent of frame length. (Result included for completeness; see capturetmp/sniff/test-abs-LC-5.txt.) - Real CH9329 chip plugged into the LC's USB-host port as the report source. The LC announced the genuine 359-byte CH9329 composite descriptor (
PID=0x29e1); the UC accepted it (p2=29e1,STATUS=0x07); the LC forwarded ~80 absolute frames (57 ab 83 0a 23 05 …) during stylus activity; the cursor did not move. Repeated across both LC ports. - Interpretation: the UC's frame-forwarding firmware is hard-bound to a subset of report shapes — relative mouse (LEN=
0x07/0x08) and keyboard (LEN=0x0B/0x0C). The descriptor handshake forwards descriptor bytes verbatim to the target host for enumeration purposes, but report payloads outside the firmware's expected set are swallowed before reaching the USB device endpoints, regardless of whether the descriptor declares them. The chip's lineage is a BIOS keyboard-and-mouse extender; arbitrary HID classes were apparently never within scope for state-0/1's report path. - Implication: absolute mouse positioning on a CH9350L UC requires state 3 or state 4 (dipswitch-fixed BIOS modes), where the UC owns the USB device stack with built-in absolute-mouse / digitizer descriptors and frames flow as fixed-format
0x04packets. State 0/1 cannot be made to support absolute mouse from the LC side alone; this is a UC firmware constraint, not a protocol negotiation issue. - Caveat: observed against one CH9350L board (single-DM/DP wired port; the same hardware used for all captures in this document). Whether other CH9350L boards or firmware revisions behave differently has not been tested.
State-3/4 0x04 X/Y is 11-bit signed, not 16-bit unsigned
- Datasheet (§4.3): documents the
0x04absolute mouse frame as57 AB 04 [id] [btn] [XL] [XH] [YL] [YH] [wheel]with X/Y as 16-bit LE fields; no statement is made about the chip's USB-side LogicalMin/LogicalMax declarations. - Common assumption (in earlier versions of this document and in the implementation): the field width = the usable range, i.e. X/Y are full 16-bit unsigned 0..0xFFFF. The CH9329's identical
0x04wire format uses 12-bit (0..4095), so 12-bit was a plausible fallback hypothesis. - Observed: cursor sweeps on
nx ∈ 0..1023, clamps at left edge onnx ∈ 1024..2047, sweeps again onnx ∈ 2048..3071, and so on with period 2048. The signature of an 11-bit signed field on the host side: bit 10 acts as the sign bit, negative-interpreted values are out ofLogicalMin=0and clamp to the left edge, and bits 11+ are silently ignored. - Effective LC-side range: 0..1023 (10-bit unsigned). X/Y on the wire remain 16-bit LE; the upper 6 bits are unused / Const padding per the chip's descriptor. A value of e.g.
0xFFFFbecomes0x07FFafter low-bit masking, gets bit-10-sign-extended to-1, and the host clamps to 0. - The chip declares a different range than the CH9329 despite the matching wire format. Implementations should treat the X/Y range as a per-chip property and calibrate empirically — the periodic clip-sweep symptom is the diagnostic signature.
Reference Implementation
ch9350_poc.py (gist) reproduces the attach sequence and emits matching 0x81 frames using HID Report Descriptors captured byte-for-byte from a real CH9350L LC. Frame content is byte-identical to what a real LC emits; frame timing (heartbeat / 0x89 cadence, inter-frame gaps) is approximate. For the curious, the reverse-engineering process for each stage of the protocol is described in more detail on issue #13.