diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 41ace49..5e223de 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -28,5 +28,8 @@ jobs: - name: Install MkDocs and Material theme run: pip install mkdocs-material + - name: Generate docs homepage from README + run: python scripts/generate_docs_index.py + - name: Deploy to GitHub Pages run: mkdocs gh-deploy --force diff --git a/.gitignore b/.gitignore index c06ed0c..8d14111 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .idea/ .vscode/ .claude/ +tmp/ CLAUDE.md .DS_Store ._.DS_Store @@ -22,6 +23,7 @@ uv.lock # MkDocs build output site/ +docs/index.md # Icon generation intermediate files assets/icon.iconset/ diff --git a/README.md b/README.md index 14a6dae..711075e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Serial KVM Controller (CH9329) +# Serial KVM Controller (CH9329 and CH9350L) [![PyPI](https://img.shields.io/pypi/v/kvm-serial)](https://pypi.org/project/kvm-serial/) [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE.md) @@ -6,17 +6,17 @@ [![Run Tests](https://img.shields.io/github/actions/workflow/status/sjmf/kvm-serial/test.yml?label=Unit%20Tests)](https://github.com/sjmf/kvm-serial/actions/workflows/test.yml) [![codecov](https://img.shields.io/codecov/c/gh/sjmf/kvm-serial)](https://codecov.io/gh/sjmf/kvm-serial) -A Software KVM, using the CH9329 UART Serial to USB HID controller. +A Software KVM for UART-to-USB-HID bridge chips (CH9329 and CH9350L). Control your computers using an emulated keyboard and mouse! -This app and python module allows you to control to a second device using a CH9329 module (or cable) -and a video capture device. You can find these from vendors on eBay and AliExpress for a low price. -However, there is very little software support available for these modules, and CH9329 -protocol documentation is sparse. +This app and python module allows you to control a second device using a UART-to-USB-HID bridge chip +(CH9329 or CH9350L) and a video capture device. You can find these from vendors on eBay and AliExpress +for a low price. However, there is very little software support available for these modules, and protocol +documentation is sparse. This software captures keyboard and mouse inputs from the local computer, sending these over a -serial UART connection to the CH9329 USB HID module, which will output USB HID mouse and keyboard +serial UART connection to the bridge chip, which will output USB HID mouse and keyboard movements and scan codes to the remote computer. The `kvm_serial` package provides options for running the GUI, or as a script providing flexible options. @@ -44,36 +44,43 @@ The GUI app will do a lot of the work for you: it will enumerate video devices a and give you a window to interact with the guest in. Application settings can be changed from the menus (File, Options, View), for example if the app doesn't select the correct devices by default. +kvm-serial supports both CH9329 and CH9350L bridge hardware. See the user guides for hardware-specific setup: +- [CH9329 User Guide](docs/CH9329_GUIDE.md) — cables, wiring, and usage for CH9329 modules +- [CH9350L User Guide](docs/CH9350L_GUIDE.md) — dipswitch configuration, working states, and usage for CH9350L modules +- [SUPPORTED_DEVICES.md](docs/SUPPORTED_DEVICES.md) — protocol and feature comparison + ## Kit List This module requires a little bit of hardware to get going. You will need: -* CH9329 module or cable +* A UART-to-USB-HID bridge chip (CH9329 or CH9350L) — optionally with an assembled cable or module * Video capture card (e.g. HDMI) You can likely get everything you need for under £30, which is incredible when compared to the price of a KVM crash cart adapter. -### CH9329 module/cable assembled as cables +### Bridge Module/Cable -_PLEASE NOTE: I am a hobbyist. I have no affiliation with any manufacturer developing or selling CH9329 hardware._ +_PLEASE NOTE: I am a hobbyist. I have no affiliation with any manufacturer developing or selling bridge hardware._ [![Home-made serial KVM module](https://wp.finnigan.dev/wp-content/uploads/2023/11/mini-uart.jpg)](https://wp.finnigan.dev/?p=682) *A home-made serial KVM module: CH9329 module soldered to SILabs CP2102. CH340 works, too.* -So, I don't have a specific vendor to recommend, but if you put "*CH9329 cable usb*" into a search -engine, you will find the right thing. Just make sure what you buy has "CH9329" in the name: a USB-A -to USB-A cable won't do, and can damage your machine. +Pre-assembled cables and modules are available from eBay and AliExpress: + +- **CH9329 cables:** Search for "*CH9329 cable usb*". Just make sure it has "CH9329" in the name; + a USB-A to USB-A cable won't do and can damage your machine. See the [CH9329 User Guide](docs/CH9329_GUIDE.md) + for full hardware and wiring details. +- **CH9350L modules:** Less common than CH9329 but available; typically come as breakout boards + with serial connector and dipswitches. See the [CH9350L User Guide](docs/CH9350L_GUIDE.md) for + dipswitch configuration and working state selection. -The modules have a USB-A male connector on one end, and serial connector on the other. The cables -have USB-A both ends, as they are already put together and should pretty much be plug-and-play: just -make sure it's the right way around. I just soldered a CH9329 module to a UART transceiver chip -myself, as above. +You can build your own by soldering a bridge chip to a UART transceiver chip (e.g. SILabs CP2102 or CH340). -### Video capture card +### Video Capture Card -You also need a capture card that takes the display output from your remote machine, and presents it -as a USB device to your local system. I found the "*UGREEN Video Capture Card HDMI to USB C Capture +You also need a capture card that takes the display output from your remote machine and presents it +as a USB device to your local system. The "*UGREEN Video Capture Card HDMI to USB C Capture Device*" was a good balance of price versus value. The more you spend on a capture device, the more responsive your video feed will likely be (to a point). HDMI and VGA hardware is available. @@ -110,9 +117,7 @@ pip install -e ".[dev]" A script called `control.py` is also provided for use directly from the terminal, so you can also control remotes from a headless environment! (e.g. Pi to Pi!) -Packages must be installed first. Use your preferred python package manager. E.g.: - - +Packages must be installed first. Use your preferred python package manager, e.g. `pip`, `uv` Usage examples for the `control.py` script: @@ -124,7 +129,7 @@ python -m kvm_serial.control uv run kvm-control # Run with mouse and video support; use a Mac OSX serial port: -python -m kvm_serial.control -ex /dev/cu.usbserial-A6023LNH +python -m kvm_serial.control -e /dev/cu.usbserial-A6023LNH # Run the script using keyboard 'tty' mode (no mouse, no video) python control.py --mode tty /dev/tty.usbserial0 @@ -134,16 +139,22 @@ sudo python control.py --mode usb /dev/tty.usbserial0 # Increase logging using --verbose (or -v), and use COM1 serial port (Windows) python control.py --verbose COM1 -``` -Use `python control.py --help` to view all available options. Keyboard capture and transmission is the default functionality of control.py: a couple of extra parameters are used to enable mouse and video. For most purposes, the default capture mode will suffice. +# Use CH9350L in state 3 (absolute mouse — recommended for desktop use) +python control.py --ch9350 --ch9350-state 3 /dev/cu.usbserial-XXXX + +# Use CH9350L in state 2 (BIOS keyboard + relative mouse — for BIOS/UEFI use) +python control.py --ch9350 --ch9350-state 2 /dev/cu.usbserial-XXXX + +# Use CH9350L in state 0 (full descriptor handshake) +python control.py --ch9350 --ch9350-state 0 /dev/cu.usbserial-XXXX +``` -Mouse capture is provided using the parameter `--mouse` (`-e`). It uses pynput for capturing mouse input and transmits this over the serial link simultaneously to keyboard input. Appropriate system permissions (Privacy and Security) may be required to use mouse capture. +Use `python control.py --help` to view all available options. By default, the CH9329 protocol is used; pass `--ch9350` to switch to CH9350L protocol. See the [CH9329 User Guide](docs/CH9329_GUIDE.md) and [CH9350L User Guide](docs/CH9350L_GUIDE.md) for hardware-specific setup and usage. -Video capture is provided using the parameter `--video` (`-x`). It uses OpenCV for capturing frames from the camera device. Again, system permissions for webcam access may need to be granted. +Mouse capture is provided using the parameter `--mouse` (`-e`). Appropriate system permissions (Privacy and Security) may be required on macOS. -See [MODES.md](./docs/MODES.MD) for more information on the various other options to the script. -Implementations are provided for all the main python input capture methods. +For live video, use the GUI (`kvm-gui`). See [MODES.md](docs/MODES.md) for keyboard capture mode options. ## Troubleshooting @@ -158,4 +169,4 @@ With thanks to [@beijixiaohu](https://github.com/beijixiaohu), the author of the Thank you, once again, to everyone who has [contributed](CONTRIBUTING.md) to this project. ## License -(c) 2023-25 Samantha Finnigan and contributors (except where acknowledged) and released under [MIT License](LICENSE.md). +(c) 2023-26 Samantha Finnigan and contributors (except where acknowledged) and released under [MIT License](LICENSE.md). diff --git a/docs/CH9329_GUIDE.md b/docs/CH9329_GUIDE.md new file mode 100644 index 0000000..a413800 --- /dev/null +++ b/docs/CH9329_GUIDE.md @@ -0,0 +1,172 @@ +# CH9329 User Guide + +The CH9329 is a UART-to-USB-HID bridge chip. It presents itself on the target PC as a composite +USB keyboard and mouse device, and receives framed commands over a TTL UART from the host +controller (kvm-serial). It is the simpler of the two supported bridge chips: no pairing handshake +or dipswitch configuration is needed, and no special flags are required in kvm-serial. CH9329 is +the default protocol. + +> **Protocol reference:** [CH9329 Protocol Specification](CH9329_PROTO.md) +> **Supported devices overview:** [Supported Devices](SUPPORTED_DEVICES.md) + +--- + +## Hardware + +### What you need + +- A CH9329 module or cable (UART-to-USB-HID) +- A USB-to-UART adapter to connect the CH9329 to your host computer, **unless** using a + pre-assembled all-in-one cable +- A USB video capture card (optional, for video feed from the remote machine) + +### Pre-assembled cables and modules + +Pre-assembled CH9329 cables are sold on eBay and AliExpress. Search for **"CH9329 cable usb"**. + +There are two common form factors: + +| Form factor | Description | +|-------------|-------------| +| **Cable** | USB-A at both ends. One end connects to the host (serial), the other to the target (USB HID). Plug-and-play — no soldering required. Make sure it has the CH9329 chip label; a plain USB-A to USB-A cable has no serial conversion and **will not work**. | +| **Module** | A small breakout board with serial header pins. Requires a separate USB-to-UART adapter (CP2102, CH340, FTDI, etc.) wired to the host computer. | + +> _Note: the author has no affiliation with any manufacturer or seller._ + +### DIY wiring + +[![Home-made serial KVM module](img/mini-uart.jpg)](https://wp.finnigan.dev/?p=682) +*A home-made serial KVM module: CH9329 chip soldered to a SILabs CP2102 USB-to-UART adapter. CH340 works too. +Note: this photo shows the 5 V pins connected — see the warning below before replicating this wiring.* + +You can build your own module by wiring a CH9329 chip to a USB-to-UART adapter: + +``` +Host PC (USB) ──► USB-to-UART adapter (e.g. CP2102 / CH340) + │ + │ TX ──► RX ┐ + │ RX ◄── TX ├─ CH9329 module + │ GND ─── GND ┘ + │ + ▼ + USB-A (target PC) +``` + +The CH9329 operates at 3.3 V or 5 V TTL. Default baud rate is **9600 bps**; the chip also supports +1200, 2400, 4800, 14400, 19200, 38400, 57600, and 115200 bps (note: 115200 is not supported at +3.3 V). For most uses, keep the default 9600 bps. + +> **⚠ Warning — do not connect the 5 V power pins together.** +> The CH9329 module is already powered by the target machine's USB port. If you also connect +> the 5 V output of the USB-to-UART adapter to the CH9329's VCC pin (as per the image above), +> you create two power sources driving the same rail. This can backfeed voltage into one or +> both USB ports, which may damage hardware that lacks modern reverse-power protection. Wire +> only **TX, RX, and GND** between the adapter and the CH9329; leave the 5 V / VCC pins unconnected. + +--- + +## Physical Setup + +1. Connect the CH9329 module/cable to the **target machine** via USB. +2. Connect the serial end (or USB-to-UART adapter) to your **host machine** via USB. +3. Verify the device appears as a serial port on the host: + - **macOS/Linux:** `/dev/cu.usbserial-XXXX` or `/dev/ttyUSB0` + - **Windows:** `COM3`, `COM4`, etc. (check Device Manager → Ports) +4. On the target machine, the CH9329 should enumerate as a USB keyboard + mouse. No drivers + are needed on the target side. + +> **Driver installation:** If the serial port does not appear on your host, you may need to install +> a USB-to-UART driver. See [INSTALLATION.md](INSTALLATION.md) for platform-specific instructions. + +--- + +## GUI Usage + +No special configuration is needed for CH9329 — it is the default protocol. + +1. Launch the GUI (`python -m kvm_serial` or the packaged executable). +2. Open **File → Connect** (or the connection toolbar). +3. Select your serial port from the drop-down list and click **Connect**. +4. If using a video capture card, select the camera under **Options → Camera**. +5. The app will begin forwarding your keyboard and mouse input to the remote machine. + +The GUI detects CH9329 automatically; no additional menu options are required. + +--- + +## Headless / CLI Usage + +Use `kvm_serial.control` for terminal-based usage without a GUI (e.g. Raspberry Pi to Pi, or +headless servers): + +```bash +# Basic keyboard forwarding only (default mode) +python -m kvm_serial.control /dev/cu.usbserial-A6023LNH + +# With mouse capture enabled +python -m kvm_serial.control --mouse /dev/cu.usbserial-A6023LNH + +# With video, use the GUI: python -m kvm_serial + +# Windows COM port +python -m kvm_serial.control COM3 + +# Verbose logging +python -m kvm_serial.control --verbose /dev/cu.usbserial-A6023LNH + +# Using uv +uv run kvm-control /dev/cu.usbserial-A6023LNH +``` + +CH9329 is the default protocol; no `--ch9350` flag is needed. Run with `--help` to see all options. + +### Keyboard capture modes + +By default, `curses` mode is used for keyboard-only, and `pynput` when mouse or video is enabled. +Other modes can be selected with `--mode`: + +```bash +python -m kvm_serial.control --mode tty /dev/cu.usbserial-A6023LNH +python -m kvm_serial.control --mode usb /dev/cu.usbserial-A6023LNH # requires root +``` + +See [MODES.md](MODES.md) for a full comparison of capture modes and their trade-offs. + +--- + +## Troubleshooting + +### Serial port not detected + +- Check that the USB-to-UART driver is installed. See [INSTALLATION.md](INSTALLATION.md). +- On Linux, your user may need serial port access: `sudo usermod -a -G dialout $USER` (then log out and back in). +- On macOS, run `ls /dev/cu.*` to list available serial ports after connecting the device. + +### No keyboard/mouse response on the target + +- Ensure the USB end of the CH9329 is connected to the **target** machine and a USB HID device + has enumerated (check the target's device manager or `lsusb`). +- Double-check you have not connected the USB-A ends the wrong way around (target ↔ host reversed). +- Try a different USB port or cable. +- Check baud rate: kvm-serial defaults to 9600 bps which matches the CH9329 factory default. + If the chip has been reconfigured to a different baud rate, pass `--baud `. + +### Keyboard input not captured on the host + +- On macOS, pynput requires **Input Monitoring** permission (System Settings → Privacy & Security). +- Try `--mode curses` or `--mode tty` as alternatives that do not require this permission. + +### Mouse coordinates appear wrong + +- CH9329 uses a 12-bit absolute coordinate space (0–4095 × 0–4095). +- kvm-serial maps screen/scene coordinates to this range automatically in the GUI. +- In headless mode, absolute mouse is sent when `--mouse` is active. + +--- + +## Further Reading + +- [CH9329 Protocol Specification](CH9329_PROTO.md) — full frame format, command reference, and worked examples +- [MODES.md](MODES.md) — keyboard capture mode comparison +- [INSTALLATION.md](INSTALLATION.md) — platform-specific driver and permission setup +- [SUPPORTED_DEVICES.md](SUPPORTED_DEVICES.md) — comparison of CH9329 and CH9350L diff --git a/docs/CH9329_PROTO.md b/docs/CH9329_PROTO.md new file mode 100644 index 0000000..e71e368 --- /dev/null +++ b/docs/CH9329_PROTO.md @@ -0,0 +1,217 @@ +# CH9329 UART Protocol Specification + +> **Manufacturer datasheet:** WCH CH9329 V1.1 — [wch.cn](http://wch.cn). Pin and electrical +> parameter references in this document refer to that datasheet. +> +> **Reference implementation:** `kvm_serial/utils/ch9329.py` — originally derived from +> [beijixiaohu/CH9329_COMM](https://github.com/beijixiaohu/CH9329_COMM/). + +--- + +## Overview + +The CH9329 is a UART-to-USB-HID bridge chip. It presents itself on the target PC as a composite +USB device (keyboard + mouse + optional custom HID) and receives framed commands over a TTL +UART from the host controller — kvm-serial in this case. The protocol is entirely +**host-driven**: the host sends command frames and the chip executes them. There is no +handshake, no descriptor exchange, and no state machine to track. + +kvm-serial uses Serial Communication **Mode 0** (Protocol Transmission mode, the default), which +requires all data to be sent as framed packets per the format below. + +--- + +## Physical Layer + +| Parameter | Value | +|-----------|-------| +| Baud rate | 9600 bps (default; configurable) | +| Data bits | 8 | +| Parity | None | +| Stop bits | 1 | +| Logic level | 3.3 V or 5 V TTL | + +Supported baud rates: 1200, 2400, 4800, 9600, 14400, 19200, 38400, 57600, 115200. +Note: 115200 bps is not supported when the chip operates at 3.3 V. + +--- + +## Frame Structure + +Every command is a single self-contained packet with the following layout: + +``` +[HEAD_HI] [HEAD_LO] [ADDR] [CMD] [LEN] [DATA...] [SUM] + 0x57 0xAB 1B 1B 1B LEN bytes 1B +``` + +| Field | Size | Description | +|-------|------|-------------| +| `HEAD` | 2B | Fixed magic header `0x57 0xAB` | +| `ADDR` | 1B | Device address — `0x00` by default; non-zero when multiple CH9329 chips share one UART bus | +| `CMD` | 1B | Command type — selects keyboard, absolute mouse, or relative mouse | +| `LEN` | 1B | Number of data bytes that follow (0–255) | +| `DATA` | LEN bytes | Payload — format is CMD-specific, described below | +| `SUM` | 1B | Checksum: `(sum(HEAD) + ADDR + CMD + LEN + sum(DATA)) mod 256` | + +Maximum `LEN` is 255; a payload of 256 or more bytes causes an `OverflowError` in the +`len.to_bytes(1)` call before the packet is even sent. + +### Checksum Worked Example + +Keyboard frame for 'a' (`scancode = 0x04`): + +``` +57 AB 00 02 08 00 00 04 00 00 00 00 00 10 +``` + +- Header sum: `0x57 + 0xAB = 0x102`; modular carry is included in the final mod-256 step. +- `SUM = (0x57 + 0xAB + 0x00 + 0x02 + 0x08 + 0x00 + 0x00 + 0x04 + 0x00×5) mod 256` +- `= (0x102 + 0x0A + 0x04) mod 256 = 0x110 mod 256 = 0x10` ✓ + +--- + +## Commands + +### Keyboard — CMD `0x02` + +Sends an 8-byte USB HID boot-protocol keyboard report directly to the chip. + +``` +57 AB [ADDR] 02 08 [mod] [rsvd] [k0] [k1] [k2] [k3] [k4] [k5] [SUM] +``` + +| Field | Size | Description | +|-------|------|-------------| +| `LEN` | `0x08` | Fixed — boot keyboard report is always 8 bytes | +| `mod` | 1B | Modifier bitmask (see below) | +| `rsvd` | 1B | Reserved — always `0x00` | +| `k0..k5` | 6B | USB HID usage IDs of currently held keys; zero-padded | + +**Modifier byte bitmask:** + +| 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 | + +Sending `00 00 00 00 00 00 00 00` releases all keys (the `release()` call). + +### Absolute Mouse — CMD `0x04` + +``` +57 AB [ADDR] 04 07 02 [btn] [XL] [XH] [YL] [YH] [wheel] [SUM] +``` + +| Field | Value | Description | +|-------|-------|-------------| +| `LEN` | `0x07` | Fixed payload length | +| marker | `0x02` | Absolute-coordinate mode indicator | +| `btn` | 1B | Button bitmask — bit 0 = left, bit 1 = right, bit 2 = middle | +| `XL`/`XH` | 2B LE | X coordinate in the chip's 12-bit absolute space (0–4095) | +| `YL`/`YH` | 2B LE | Y coordinate in the chip's 12-bit absolute space (0–4095) | +| `wheel` | 1B signed | Scroll wheel delta, encoded as a signed byte (see §Signed Byte Encoding) | + +**Coordinate scaling.** Source pixel coordinates are scaled into the chip's 12-bit space: + +$$dx = \left\lfloor \frac{4096 \times x}{\max(1,\, width)} \right\rfloor$$ + +Negative coordinates (e.g. from multi-monitor setups where a pointer can be off-screen left/above +the primary display) wrap via `abs(4096 + dx)` rather than clamping, so motion remains continuous +across monitor boundaries. + +### Relative Mouse — CMD `0x05` + +``` +57 AB [ADDR] 05 05 01 [btn] [dx] [dy] [wheel] [SUM] +``` + +| Field | Value | Description | +|-------|-------|-------------| +| `LEN` | `0x05` | Fixed payload length | +| marker | `0x01` | Relative-coordinate mode indicator | +| `btn` | 1B | Button bitmask — same encoding as absolute mouse | +| `dx` | 1B signed | Horizontal delta (−127..+127) | +| `dy` | 1B signed | Vertical delta (−127..+127) | +| `wheel` | 1B signed | Scroll wheel delta (−127..+127) | + +Out-of-range deltas are **clamped** (not wrapped) to the signed-byte limits. + +--- + +## Signed Byte Encoding + +All signed single-byte fields (`dx`, `dy`, `wheel`) use standard two's-complement encoding, +clamped to −127..+127 (not the full −128..+127 — the implementation deliberately excludes −128 +to keep encoding unambiguous). Python's `int.to_bytes(1, signed=True)` encodes this directly. + +| Decimal | Hex | +|---------|-----| +| +127 | `0x7F` | +| +1 | `0x01` | +| 0 | `0x00` | +| −1 | `0xFF` | +| −127 | `0x81` | + +--- + +## Multi-Chip Addressing + +The `ADDR` byte allows multiple CH9329 chips to share a single UART bus. Each chip can be +configured with a non-zero address; a frame is only acted on by the chip whose address matches. +`0x00` is the factory default and the value used by kvm-serial in all frames. + +> **Note:** Multi-chip addressing on the same serial bus is not available in kvm-serial. Users +> requiring multiple device control can use multiple serial devices to interface to multiple chips. + +--- + +## Operating Mode and Communication Mode + +The chip's mode is set at hardware level via the `MODE0`/`MODE1` and `CFG0`/`CFG1` input pins +(pulled high by default → Mode 0 / Communication Mode 0). + +| MODE1 | MODE0 | Operating Mode | USB Device Identity | +|-------|-------|---------------|---------------------| +| 1 | 1 | **0 (default)** | Composite: keyboard + mouse + custom HID | +| 1 | 0 | 1 | Keyboard only (no multimedia keys) | +| 0 | 1 | 2 | Keyboard + mouse (no custom HID) — recommended for Linux/macOS | +| 0 | 0 | 3 | Custom HID only (raw data passthrough) | + +kvm-serial uses **Mode 0** (both pins high / floating). All three HID functions — keyboard, +absolute mouse, and relative mouse — are available simultaneously in this mode. + +| CFG1 | CFG0 | Serial Communication Mode | +|------|------|--------------------------| +| 1 | 1 | **0 (default)** — Protocol Transmission (framed packets) | +| 1 | 0 | 1 — ASCII mode (printable characters only) | +| 0 | 1 | 2 — Transparent mode (raw 8-byte keyboard data) | + +kvm-serial requires **Communication Mode 0**. ASCII and transparent modes are outside the scope +of this document. + +--- + +## No Handshake Required + +Unlike the CH9350L (which needs a descriptor handshake before forwarding input), the CH9329 +acts on command frames immediately after USB enumeration completes. There are no startup frames +the host must send, no keep-alive to maintain, and no state machine to track. The chip's +`ACT#` output pin goes low once USB enumeration has succeeded, but kvm-serial does not monitor +this pin — it simply begins sending frames when the serial port is opened. + +--- + +## Summary of Command Bytes + +| CMD | Description | `LEN` | Payload | +|-----|-------------|-------|---------| +| `0x02` | Keyboard | `0x08` | `mod rsvd k0 k1 k2 k3 k4 k5` | +| `0x04` | Absolute mouse | `0x07` | `0x02 btn XL XH YL YH wheel` | +| `0x05` | Relative mouse | `0x05` | `0x01 btn dx dy wheel` | diff --git a/docs/CH9350L_GUIDE.md b/docs/CH9350L_GUIDE.md new file mode 100644 index 0000000..d681cd0 --- /dev/null +++ b/docs/CH9350L_GUIDE.md @@ -0,0 +1,310 @@ +# CH9350L User Guide + +The CH9350L is a paired-chip USB extender. Unlike the single-chip CH9329, it is designed to +operate as a matched pair: a **Lower Computer (LC)** module (USB host side) and an **Upper +Computer (UC)** module (USB device side). kvm-serial replaces the LC in software, speaking the +CH9350L UART protocol directly to a physical UC module, which presents USB HID keyboard and +mouse to your target machine. + +> **Protocol reference:** [CH9350L Protocol Specification](CH9350L_PROTO.md) +> **Supported devices overview:** [Supported Devices](SUPPORTED_DEVICES.md) + +--- + +## Hardware + +### What you need + +- A CH9350L UC (Upper Computer) module — the end that connects to the target machine +- A short USB-A to USB-A male to male cable to connect the CH9350L module to the target +- A USB-to-UART adapter connecting the UC's serial header to your host computer + (e.g. CP2102, CH340, FTDI, or similar at 3.3 V TTL) +- A USB video capture card (optional, for video feed from the remote machine) + +> **Note:** You only need the UC board. kvm-serial software replaces the LC. While you don't need +> two CH9350L modules many sellers only sell them in pairs, and redundancy is sometimes useful. + +### Sourcing hardware + +CH9350L modules are less common than CH9329 cables but are available from eBay and AliExpress. +They typically come as small breakout boards with: + +- A USB-A connector for the target machine (this is the UC's USB device port) +- A serial/UART header for communication +- Three dipswitches: **SEL**, **S0**, **S1** (and sometimes **BAUD0**/**BAUD1**) + +Look for listings describing a "CH9350 KVM extender" or "CH9350L module" breakout board. + +Here is an example of a typical CH9350L breakout module (TTL UART variant): + +![CH9350L module front — USB-A connector, CH9350L chip, and DIP switches](img/ch9350l-front.jpg) +*Front: USB-A target connector (left), CH9350L chip (centre), DIP switches S0/S1/BA0/BA1/SEL (right), and serial header* + +--- + +## Dipswitch Configuration + +This is the most important setup step. The dipswitches on the UC board control its role and +working state. + +![CH9350L module back — dipswitch silkscreen in Chinese with English translations below](img/ch9350l-back.jpg) +*Back silkscreen: 功能设置 (Function Settings) — labels for S0, S1, BA0, BA1, SEL, and 485控制 (RS-485 direction control)* + +The silkscreen labels translate as: + +| Label | Translation | +|-------|-------------| +| S0 | Working state setting | +| S1 | Default is 0/1 | +| BA0 | Baud rate setting, default is | +| BA1 | 115200 | +| SEL | 1: Lower computer (LC) — 0: Upper computer (UC) | +| 485控制 | Direction control pin for external RS-485 connection | + +Out of the box, all switches are set to 1 (HIGH). The silkscreen on the front reads `0` and `1` at the ends of the DIP switch block, indicating switch orientation (1 = ON/HIGH, 0 = OFF/LOW). + +### SEL — Role selection + +| SEL | Role | +|-----|------| +| 1 (ON) | Lower Computer (LC) — USB host side | +| 0 (OFF) | Upper Computer (UC) — USB device side | + +Set **SEL = 0** (OFF) to configure the module as a UC. kvm-serial is the LC. + +### S0 / S1 — Working state + +The working state determines what USB HID descriptors the UC presents to the target machine +and which UART frame format is used. + +| S0 | S1 | State | Description | +|----|----|-------|-------------| +| HIGH | HIGH | 0/1 (default) | Full descriptor handshake — kvm-serial sends HID descriptors over UART | +| LOW | HIGH | 2 | BIOS keyboard + **relative** mouse (legacy/BIOS compatible) | +| HIGH | LOW | 3 | BIOS keyboard + **absolute** mouse (recommended for most desktop use) | +| LOW | LOW | 4 | BIOS keyboard + **HID Digitizers** (multi-monitor absolute positioning) | + +**Which state should I use?** + +- **State 3** is the recommended starting point for most users controlling a modern desktop OS + with an absolute mouse pointer (the cursor goes exactly where you click in the video feed). + Because reports carry positions rather than deltas, target-side pointer acceleration has no + effect — the cursor lands exactly where the source pointer is in the video feed. +- **State 2** is the right choice for BIOS/UEFI setup screens, boot menus, PXE boot, and + recovery consoles. Legacy BIOS and UEFI CSM only enumerate USB HID boot-protocol devices + with a relative mouse; the absolute-mouse descriptor used in states 3/4 will not enumerate + in those environments. The cursor is driven by relative deltas, so target-side pointer + acceleration applies (see the troubleshooting note below). +- **State 4** is for multi-monitor setups using HID Digitizer absolute positioning. +- **State 0/1** is the most advanced option: the UC mirrors descriptors from real USB HID + devices. It supports relative mouse only (absolute mouse frames are silently dropped by the + UC firmware in this state) and inherits target-side pointer acceleration as a result. Most + users should prefer state 3 or 4. + +> **BIOS/UEFI tip:** If you need to access the BIOS on the target machine, temporarily +> switch to state 2 (change S0/S1 and power-cycle the board). You can switch back to state 3 +> after booting into the OS. + +> **Important:** Set S0/S1 on the **UC board only**. When using kvm-serial as the software LC, +> only the UC board's dipswitches matter. If you purchased a pair of modules, keep the unused +> LC board aside as a spare. + +### BAUD0 / BAUD1 — Baud rate + +| BAUD1 | BAUD0 | Baud rate | +|-------|-------|-----------| +| 0 | 0 | 115200 (default) | +| 0 | 1 | 57600 | +| 1 | 0 | 38400 | +| 1 | 1 | 9600 | + +CH9350L defaults to **115200 bps**. You will need to select the correct baud rate for the hardware +in the `baud` menu in kvm-serial. + +--- + +## Physical Setup + +1. Set the dipswitch **SEL = 0** (UC mode) and choose a working state (S0/S1). +2. Connect the UC module's USB-A connector to the **target machine**. +3. Connect the UC module's serial/UART header to your **host machine** via a 3.3 V USB-to-UART + adapter (e.g. CP2102, CH340): + + ``` + Host PC (USB) ──► USB-to-UART adapter (3.3 V TTL) + │ + │ TX ──► RX ┐ + │ RX ◄── TX ├─ CH9350L UC module + │ GND ── GND ┘ + │ + ▼ + USB-A (target PC) + ``` + +4. Verify the serial port appears on the host: + - **macOS/Linux:** `/dev/cu.usbserial-XXXX` or `/dev/ttyUSB0` + - **Windows:** `COM3`, `COM4`, etc. (check Device Manager → Ports) + +5. On the target machine, the UC should enumerate as a USB keyboard + mouse device. + +> **Driver installation:** If the serial port is not detected, see [INSTALLATION.md](INSTALLATION.md) +> for platform-specific USB-to-UART driver instructions. + +### USB port note + +The CH9350L module has two USB-A port positions on the target side. The front silkscreen marks +them: + +- **Upper row: DM / DP** — this is typically the wired USB port +- **Lower row: HM / HP** — this may not have signal lines connected on some boards + +If the target machine does not enumerate a USB HID device, try the other port. The correct port +is the one where the target's keyboard LED indicators (Num Lock, Caps Lock) react when toggled +from the host. + +--- + +## RS-485 vs TTL UART + +The CH9350L supports both plain TTL UART and RS-485 on the same UART pins. + +**TTL UART (3.3 V single-ended)** — recommended for most kvm-serial setups. Direct wiring to a +USB-to-UART adapter (CP2102, CH340, FTDI, etc.) works fine for typical desktop or lab distances. +A Raspberry Pi GPIO header can also be wired directly. + +**RS-485 (differential, half-duplex)** — extends the link to tens of metres at 115200 bps, +or up to ~1200 m at lower baud rates, using a balanced twisted-pair cable. + +> **⚠ Warning — do not connect RS-485 bus lines directly to the CH9350L.** +> RS-485 uses differential voltages in the range of −7 V to +12 V. The CH9350L's UART pins +> are 3.3 V TTL and will be damaged by RS-485 bus voltages. This could even potentially damage +> the machine you are connecting to, over-volting its USB lines. You **must** use a separate +> RS-485 transceiver board (e.g. one based on the MAX485 or SN75176) to convert between the +> CH9350L's TTL UART and the RS-485 bus. + +The CH9350L's **TNOW** pin (labelled "485控制" / "485 Control" on the board silkscreen) goes +HIGH during transmission. Wire this to the RE/DE direction-control pins of the transceiver +board to switch it between transmit and receive — this is required because RS-485 is +half-duplex. The `0x57 0xAB` UART frames are identical over RS-485; no protocol change is +needed in kvm-serial. + +For most users, **TTL UART is sufficient and requires no additional hardware**. + +--- + +## GUI Usage + +1. Launch the GUI (`python -m kvm_serial` or the packaged executable). +2. Open **Options → Protocol** and select a CH9350L option. + * **CH9350L (state 3, absolute mouse)** is recommended for GUI usage. + * Ensure the dipswitches on your hardware match the selected option. +3. Open **Options → Baud** and select **115200** as the default for this chip. +4. kvm-serial will automatically perform any startup handshake and begin forwarding keyboard and mouse input. +5. Open **File → Save Configuration** to persist this configuration across restarts. + +The GUI will indicate the connection state. For state 0/1, a descriptor handshake occurs on +first connect; for states 2/3/4 the UC accepts input frames immediately after the startup +sequence. + +You can tell the handshake has succeeded when keyboard and mouse input starts working on the +target machine. If input does not work after a few seconds, see +[Troubleshooting](#troubleshooting) below. + +--- + +## Headless / CLI Usage + +Use `kvm_serial.control` with the `--ch9350` flag: + +```bash +# CH9350L in state 3 (absolute mouse — recommended for desktop use) +python -m kvm_serial.control --ch9350 --ch9350-state 3 /dev/cu.usbserial-XXXX + +# CH9350L in state 2 (relative mouse — for BIOS/UEFI use) +python -m kvm_serial.control --ch9350 --ch9350-state 2 /dev/cu.usbserial-XXXX + +# CH9350L in default state (0/1 — descriptor handshake, relative mouse only) +python -m kvm_serial.control --ch9350 /dev/cu.usbserial-XXXX + +# With mouse capture +python -m kvm_serial.control --ch9350 --ch9350-state 3 --mouse /dev/cu.usbserial-XXXX + +# For video, use the GUI: python -m kvm_serial + +# Verbose logging (useful for diagnosing handshake issues) +python -m kvm_serial.control --ch9350 --verbose /dev/cu.usbserial-XXXX + +# Windows COM port +python -m kvm_serial.control --ch9350 --ch9350-state 3 COM3 +``` + +Run with `--help` to see all available options. + +--- + +## Troubleshooting + +### No keyboard or mouse input on the target + +The UART link may be healthy while the UC's USB port has not enumerated on the target host. +Common causes: + +- **Wrong USB port on the UC board.** The module has two USB-A port positions (DM/DP upper, + HM/HP lower); on some boards only one has the signal lines wired. Try the other if you're + not having luck with the first. +- **Dipswitch mismatch.** Ensure SEL = 0 (UC) and S0/S1 match the state you specified with + `--ch9350-state`. Power-cycle the board after changing dipswitches. +- **Wrong baud rate selected** Check the `BA0/BA1` dipswitches from the table above, and + the baud rate setting in `kvm-serial`. These MUST match for the link to work. +- **Wrong end of the cable.** The USB-A connector must go to the **target**, not the host. +- **Target USB port issues.** Try a different USB port on the target machine. + +### Cursor does not move in state 0/1 + +Absolute mouse is silently dropped by the CH9350L UC firmware in state 0/1. This is a +hardware/firmware limitation. Use **state 3** or **state 4** for absolute mouse positioning. + +### Cursor lags behind on fast drags (states 0/1, 2) + +In states 0/1 and 2 kvm-serial converts the source pointer's absolute position into relative +deltas, which means the cursor on the target is driven by the same path as any USB mouse +plugged in directly — including the target's **pointer acceleration**. A slow drag tracks +the source faithfully; a fast drag overshoots; a deliberate slow-down lets the source catch +up. This is a property of relative-mouse input on accelerated OSes, not a kvm-serial bug. + +Mitigations: + +- **Switch to state 3** if the target supports it. State 3 ships positions rather than deltas + so target-side acceleration has no effect. +- **Disable target-side pointer acceleration** if state 3 isn't an option: + - Windows: Settings → Devices → Mouse → Additional mouse options → Pointer Options → + uncheck *Enhance pointer precision* + - macOS: `defaults write -g com.apple.mouse.scaling -1` then log out (no GUI toggle) + - Linux: per-DE — typically `xinput set-prop` for X11, or the desktop environment's + mouse settings panel + +### Descriptor handshake never completes (state 0/1) + +- Run with `--verbose` to see handshake progress. +- The handshake requires the UC to echo back PID values in its keep-alive (`0x12`). If the + UC's keep-alive never appears, check baud rate (115200), wiring (TX/RX not crossed?), and + that the UART adapter is 3.3 V TTL. +- Try a target machine replug: if the handshake completes but input still does not work, unplug + and re-plug the USB cable on the **target** side. kvm-serial will replay the attach sequence + automatically. + +### Serial port not detected on the host + +See [INSTALLATION.md](INSTALLATION.md) for USB-to-UART driver installation instructions. +On Linux, add your user to the `dialout` group: `sudo usermod -a -G dialout $USER` (then log +out and back in). + +--- + +## Further Reading + +- [CH9350L Protocol Specification](CH9350L_PROTO.md) — full frame format, state machine, attach + sequence, and worked examples +- [MODES.md](MODES.md) — keyboard capture mode comparison +- [INSTALLATION.md](INSTALLATION.md) — platform-specific driver and permission setup +- [SUPPORTED_DEVICES.md](SUPPORTED_DEVICES.md) — comparison of CH9329 and CH9350L diff --git a/docs/CH9350L_PROTO.md b/docs/CH9350L_PROTO.md new file mode 100644 index 0000000..c45df61 --- /dev/null +++ b/docs/CH9350L_PROTO.md @@ -0,0 +1,512 @@ +# 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 the `0x81` Device 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) including `0x80` LED feedback, +> state-2 relative mouse, and state-3/4 absolute mouse. +> +> **Manufacturer datasheet:** WCH CH9350 V2.3 — [wch-ic.com/downloads/CH9350DS_PDF.html](https://www.wch-ic.com/downloads/CH9350DS_PDF.html). Section references in this document refer to that datasheet. See [§Divergences from the datasheet](#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 +> https://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](#states-234-alternative-dipswitch-configurations)); `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: + +``` +57 AB [CMD] [payload...] +``` + +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`) + +``` +57 AB [CMD] [LEN] [SER] [report-bytes...] [CTR] [CTR_SUM] +``` + +- **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 256` + where `report-bytes` is 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](#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. + +``` +57 AB 82 [IO] +``` + +| Byte | Meaning | +|------|---------| +| `IO` | High nibble = `0xA` (fixed); low nibble = IO0/IO1/IO3/IO4 pin state. `0xA3` typical (all inputs high) | + +### Status Announce — CMD `0x89` + +``` +57 AB 89 +``` + +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](#divergences-from-the-datasheet) 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. + +``` +57 AB 81 [PORT] [LEN_LO] [LEN_HI] [DESCRIPTOR...] [PID_LO] [PID_HI] [CHK] +``` + +| 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 `ID` and the trailing 2-byte field `2-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 as `PORT` and `PID`. + +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` + +``` +57 AB [CMD] 0C [SER] [RID] [mod] [rsvd=00] [k0] [k1] [k2] [k3] [k4] [k5] [CTR] [CTR_SUM] +``` + +| 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. + +``` +57 AB [CMD] 0B [SER=0x11] [mod] [rsvd=00] [k0] [k1] [k2] [k3] [k4] [k5] [CTR] [CTR_SUM] +``` + +| 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](#divergences-from-the-datasheet). + +#### 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. + +``` +57 AB [CMD] 0A [SER=0x23] [RID=05] [btn] [XL] [XH] [YL] [YH] [wheel] [CTR] [CTR_SUM] +``` + +| 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=`0x0A` mouse frames to the target host in state 0/1.** Verified empirically: the LC sends valid `0x83 0x0A` absolute frames with correct framing, the UC's `0x12` keep-alive shows `STATUS=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](#divergences-from-the-datasheet) 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 `0x04` frames and the UC owns the USB device descriptors directly. See [§States 2/3/4](#states-234-alternative-dipswitch-configurations). + +#### Relative mouse with report ID — LEN = `0x08` + +Produced by a real mouse on **port 1** whose descriptor contains a Report ID. + +``` +57 AB [CMD] 08 [SER=0x22] [RID] [btn] [dx] [dy] [wheel] [CTR] [CTR_SUM] +``` + +| 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. + +``` +57 AB [CMD] 07 [SER=0x20] [btn] [dx] [dy] [wheel] [CTR] [CTR_SUM] +``` + +| 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` + +``` +57 AB 86 +``` + +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](#attach-sequence-timeline) for the attach case and [§Disconnect Sequence](#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](#divergences-from-the-datasheet). + +### Status / LED — CMD `0x80` + +``` +57 AB 80 [VAL] +``` + +Single-byte payload, dual-purpose: + +- **Startup (`VAL=0xFF`):** sent twice ~210–260 ms apart immediately after the attach `0x86`. Means "LED state unknown" (pre-enumeration default). +- **State-2/3/4 LED feedback (`VAL=0x3N`):** during operation the LC emits `0x80 (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 of `0x12`'s `LED` byte (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. + +``` +57 AB 12 [P1_LO] [P1_HI] [P2_LO] [P2_HI] [LED] [STATUS] [VERSION_HI] [VERSION_LO] +``` + +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](#re-attach-on-the-ucs-usb-device-side-target-replug)), 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](#state-machine) for the transition rule and [§Divergences](#divergences-from-the-datasheet) for the comparison with the datasheet. + +> **Diagnostic — `STATUS` stuck at `0x04`.** If the LC-side handshake completes (PIDs ack'd, state 1 entered) but `STATUS` persists at `0x04` and `LED` persists at `0xFF`, 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 reaches `STATUS=0x07`. Confirmed by swapping ports: LC frames were byte-identical, but `STATUS` never moved off `0x04` on the wrong port. Identify the correct port empirically: the one where `STATUS` reaches `0x07` and 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](https://gist.github.com/sjmf/c1412b40e38f44738278c52416d5c0a9)), 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](#divergences-from-the-datasheet) 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=`0x0A` absolute-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 — CMD `0x83` / `0x88`](#mouse--cmd-0x83--0x88) and [§Divergences](#divergences-from-the-datasheet). + +--- + +## 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_PROTOCOL` modes: +> - `HID` (`01`) → Report Protocol → frame carries an `RID` byte after SER (LEN = `0x0C` keyboard, `0x0A`/`0x08` mouse). +> - `BIOS` (`10`) → Boot Protocol → fixed-layout 8-byte keyboard / 3-byte mouse report, no `RID`. +> - `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 the `RID`-less LEN = `0x0B` / `0x07` variants. +> +> 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](https://gist.github.com/sjmf/9bf8975412ecbb0985bd4e5c549a915d), [state-3/4 gist](https://gist.github.com/sjmf/5a3fd16fbe81668eddacb0fb4951e09b). + +**Common to all three modes (2, 3, 4):** + +- **Startup announce.** The LC emits `0x86 → 0x80 0xFF → 0x89 → 0x80 0xFF` at attach, identical to state 0/1 but with **no `0x81` Device 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 emitting `0x12` keep-alives at ~1 s cadence and `STATUS` reaches `0x07`. `P1`/`P2` stay at `0000` for the entire session (no descriptor announce, nothing to ack), confirming that `STATUS` bits are gated on USB-side enumeration on the target, **not** on PID-ack. +- **`0x80 0x3N` LED-feedback channel** from LC → UC during operation, mirroring the host's keyboard LED state back to the source keyboard. Example: pressing CapsLk → LC emits `01 00 00 39 ...` → UC responds with `12 ... led=0x02` → LC echoes `80 32`; on release the LC drops back to `80 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 `0x02` format 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 00` frames (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 no `0x04` frames 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 `0x02` frames on the wire** — only `0x04`. State-2 relative-mouse and state-3/4 absolute-mouse modes are mutually exclusive. +- **`0x04` format verified.** `57 AB 04 0x01 [btn] [XL XH] [YL YH] [wheel]` — 7-byte payload after the cmd byte; `id=0x01` is 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 on `nx ∈ 0..1023`, clamps at the left edge for `nx ∈ 1024..2047` (negative-interpreted), sweeps again on `nx ∈ 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 same `0x04` wire 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 `0x04` abs-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 `0x04` frames 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 isolated `0x04` per 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 REPL `m X Y` issues 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):** `0x86` is the **first** frame the LC emits after a USB device is plugged in, followed by `0x80 0xFF` ×2, heartbeats, `0x89`, and one `0x81` per device. +- **Observed (disconnect):** the LC emits a *bare* `0x86` per device unplugged, with no follow-up frames. The opcode is the same in both contexts; the presence or absence of subsequent `0x80`/`0x89`/`0x81` frames is what disambiguates. +- **No chip reset:** after both devices are disconnected, the UC's `0x12` keep-alive continues at its normal cadence with its previously-learned PIDs in the `P1`/`P2` slots. 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 0xFF` is sent twice (~210–260 ms apart) at attach time, immediately after `0x86`. The `0xFF` payload means "LED state unknown" (pre-enumeration default). +- **Observed in states 2/3/4:** the same `0x80 0xFF` startup pair, plus a recurring `0x80 0xNN` LED-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 `0x81` at attach time **while still in state 0** (CMD `0x88`). 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 two `ID` fields are not given distinct names. +- **Observed:** the leading 1-byte `ID` selects the USB port (`0x00` = port 1 / DP-DM, `0x01` = port 2 / HP-HM). The trailing 2-byte `ID` is the device PID and is reflected verbatim into the UC's `0x12` keep-alive in the corresponding `PID-port1` / `PID-port2` slot once the descriptor is processed. This document calls them `PORT` and `PID` for 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 `0x12` from the UC is the trigger. +- **Observed:** the UC sends `0x12` keep-alives starting ~30 ms after the second `0x80 0xFF`, well before any `0x81` has been sent — and continues sending them with `00 00` PIDs until each `0x81` is processed. The LC does not transition to state 1 (CMD `0x83`) until the UC's `0x12` reflects **every** PID the LC has announced via `0x81`. This was proven by bidirectional capture: with two devices announced, the LC stayed in state 0 until both `P1` and `P2` were 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) and `0x20` (mouse / Unknown / port 1) are routinely sent by a real LC for keyboards and mice whose USB descriptors lack a `Report ID` item, 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 a `Report ID` byte preceding the 4-byte boot mouse data. This is what the LC emits when the connected USB mouse has a Report Descriptor that includes a `Report ID` item 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=`0x05` absolute 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`'s `P1`/`P2` slot, `STATUS` reaches `0x07`, 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`/`0x08` relative-mouse frames and LEN=`0x0B`/`0x0C` keyboard frames immediately, against the same descriptor exchange. +- **Evidence (multiple sources):** + - PoC-generated `0x83 0x0A` frames with several composite-mouse descriptor variants — two-Application TLCs, single Pointer Physical with two reports, with/without `Wheel` declarations, with 0..65535 / 0..32767 / 0..4095 absolute coordinate ranges, with Report IDs `0x02` and `0x05`. Relative reports (RID `0x01`) 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 `0x04` instead of RID `0x01` against a descriptor declaring both — to test whether the UC's frame validator is RID-pinned independent of frame length. (Result included for completeness; see capture `tmp/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 `0x04` packets. 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 `0x04` absolute mouse frame as `57 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 `0x04` wire 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 on `nx ∈ 1024..2047`, sweeps again on `nx ∈ 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 of `LogicalMin=0` and 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. `0xFFFF` becomes `0x07FF` after 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](https://gist.github.com/sjmf/c4329fd27e403a264648bf4e7744655a)) 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](https://github.com/sjmf/kvm-serial/issues/13). diff --git a/docs/SUPPORTED_DEVICES.md b/docs/SUPPORTED_DEVICES.md new file mode 100644 index 0000000..f89e50f --- /dev/null +++ b/docs/SUPPORTED_DEVICES.md @@ -0,0 +1,34 @@ +# Supported Devices + +kvm-serial supports two UART-to-USB-HID bridge chip families. + +## CH9329 + +The CH9329 is a command-driven chip where the host sends HID reports over UART and the chip translates them into USB HID input on the target machine. + +- Simple command-style protocol +- 4095x4095 12-bit absolute mouse coordinate space +- Relative mouse support +- Keyboard boot protocol + +Protocol reference: [CH9329 Protocol](CH9329_PROTO.md) + +## CH9350L + +The CH9350L is a paired-chip extender. kvm-serial replaces the lower computer (LC) side in software and drives an upper computer (UC) module that presents USB HID to the target machine. + +- Four working states selected via UC dipswitches +- State 0/1: full HID descriptor passthrough with pairing handshake +- State 2: BIOS keyboard + relative mouse for legacy BIOS / UEFI CSM +- State 3: BIOS keyboard + absolute mouse for modern OS use +- State 4: BIOS keyboard + absolute mouse + HID Digitizers for multi-monitor setups +- 65535x65535 16-bit absolute mouse coordinate space in states 3/4 +- Heartbeat and keep-alive synchronization + +Protocol reference: [CH9350L Protocol](CH9350L_PROTO.md) + +## Hardware Notes + +- CH9329 hardware is commonly sold as pre-assembled cables or small modules. +- CH9350L hardware is more commonly sold as breakout boards with dipswitches. +- Both families are typically used with a USB-to-UART adapter such as CP2102, CH340, FTDI, or similar. \ No newline at end of file diff --git a/docs/img/ch9350l-back.jpg b/docs/img/ch9350l-back.jpg new file mode 100644 index 0000000..86d59a6 Binary files /dev/null and b/docs/img/ch9350l-back.jpg differ diff --git a/docs/img/ch9350l-front.jpg b/docs/img/ch9350l-front.jpg new file mode 100644 index 0000000..2ebe1e3 Binary files /dev/null and b/docs/img/ch9350l-front.jpg differ diff --git a/docs/img/mini-uart.jpg b/docs/img/mini-uart.jpg new file mode 100644 index 0000000..cad0cb3 Binary files /dev/null and b/docs/img/mini-uart.jpg differ diff --git a/docs/index.md b/docs/index.md deleted file mode 100644 index 85dd24a..0000000 --- a/docs/index.md +++ /dev/null @@ -1,161 +0,0 @@ -# Serial KVM Controller (CH9329) - -[![PyPI](https://img.shields.io/pypi/v/kvm-serial)](https://pypi.org/project/kvm-serial/) -[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://github.com/sjmf/kvm-serial/blob/main/LICENSE.md) -[![Black](https://img.shields.io/badge/code%20style-black-black)](https://github.com/sjmf/kvm-serial/actions/workflows/lint.yml) -[![Run Tests](https://img.shields.io/github/actions/workflow/status/sjmf/kvm-serial/test.yml?label=Unit%20Tests)](https://github.com/sjmf/kvm-serial/actions/workflows/test.yml) -[![codecov](https://img.shields.io/codecov/c/gh/sjmf/kvm-serial)](https://codecov.io/gh/sjmf/kvm-serial) - -A Software KVM, using the CH9329 UART Serial to USB HID controller. - -Control your computers using an emulated keyboard and mouse! - -This app and python module allows you to control to a second device using a CH9329 module (or cable) -and a video capture device. You can find these from vendors on eBay and AliExpress for a low price. -However, there is very little software support available for these modules, and CH9329 -protocol documentation is sparse. - -This software captures keyboard and mouse inputs from the local computer, sending these over a -serial UART connection to the CH9329 USB HID module, which will output USB HID mouse and keyboard -movements and scan codes to the remote computer. - -The `kvm_serial` package provides options for running the GUI, or as a script providing flexible options. - - App icon - -
- -__[Download the latest release](https://github.com/sjmf/kvm-serial/releases/latest/)__ for Windows, Mac or Linux. - -*See [Installation](INSTALLATION.md) for information on installing serial drivers, if required.* - - -## GUI Usage - -Run the GUI using the [executable for your platform](https://github.com/sjmf/kvm-serial/releases/latest/), or with Python using `python -m kvm_serial`. - -![KVM Window](https://wp.finnigan.dev/wp-content/uploads/2025/09/output-4.gif) -*The Serial KVM window running on OSX, controlling a Windows remote machine* - -The module can be [installed from PyPI](https://pypi.org/project/kvm-serial/) (`pip install kvm-serial`), -or locally from a cloned git repo (`pip install -e .`). - -The GUI app will do a lot of the work for you: it will enumerate video devices and serial ports, -and give you a window to interact with the guest in. Application settings can be changed from the -menus (File, Options, View), for example if the app doesn't select the correct devices by default. - -## Kit List - -This module requires a little bit of hardware to get going. You will need: - -* CH9329 module or cable -* Video capture card (e.g. HDMI) - -You can likely get everything you need for under £30, which is incredible when compared to the -price of a KVM crash cart adapter. - -### CH9329 module/cable assembled as cables - -_PLEASE NOTE: I am a hobbyist. I have no affiliation with any manufacturer developing or selling CH9329 hardware._ - -[![Home-made serial KVM module](https://wp.finnigan.dev/wp-content/uploads/2023/11/mini-uart.jpg)](https://wp.finnigan.dev/?p=682) -*A home-made serial KVM module: CH9329 module soldered to SILabs CP2102. CH340 works, too.* - -So, I don't have a specific vendor to recommend, but if you put "*CH9329 cable usb*" into a search -engine, you will find the right thing. Just make sure what you buy has "CH9329" in the name: a USB-A -to USB-A cable won't do, and can damage your machine. - -The modules have a USB-A male connector on one end, and serial connector on the other. The cables -have USB-A both ends, as they are already put together and should pretty much be plug-and-play: just -make sure it's the right way around. I just soldered a CH9329 module to a UART transceiver chip -myself, as above. - -### Video capture card - -You also need a capture card that takes the display output from your remote machine, and presents it -as a USB device to your local system. I found the "*UGREEN Video Capture Card HDMI to USB C Capture -Device*" was a good balance of price versus value. The more you spend on a capture device, the more -responsive your video feed will likely be (to a point). HDMI and VGA hardware is available. - -## Installing Python Dependencies - -_Note:_ These instructions are not required if using the executables, but you may need to do some other setup. See [Installation](INSTALLATION.md) for information on installing serial drivers. - -**Standard installation** (running the application from `pip`): - -```bash -# OPTIONAL: Create and activate a Virtual environment -python -m venv ./.venv -./.venv/scripts/activate - -# Install the module from PyPI and run the GUI -pip install kvm-serial -python -m kvm-serial -``` - -OR using [`uv` package manager](https://docs.astral.sh/uv) (a faster alternative to pip, if available): -*Note: `uv run` may not work on Windows. See [#15](https://github.com/sjmf/kvm-serial/issues/15).* - -```bash -uv run kvm-gui -``` - -**Install from source** (for development- includes PyInstaller for building executables, pytest for testing, etc.): - -```bash -pip install -e ".[dev]" -``` - -## Script Usage - -A script called `control.py` is also provided for use directly from the terminal, so you can also control remotes from a headless environment! (e.g. Pi to Pi!) - -Packages must be installed first. Use your preferred python package manager. E.g.: - - - -Usage examples for the `control.py` script: - -```bash -# Run using module -python -m kvm_serial.control - -# Run using `uv` -uv run kvm-control - -# Run with mouse and video support; use a Mac OSX serial port: -python -m kvm_serial.control -ex /dev/cu.usbserial-A6023LNH - -# Run the script using keyboard 'tty' mode (no mouse, no video) -python control.py --mode tty /dev/tty.usbserial0 - -# Run using `pyusb` keyboard mode (which requires root): -sudo python control.py --mode usb /dev/tty.usbserial0 - -# Increase logging using --verbose (or -v), and use COM1 serial port (Windows) -python control.py --verbose COM1 -``` - -Use `python control.py --help` to view all available options. Keyboard capture and transmission is the default functionality of control.py: a couple of extra parameters are used to enable mouse and video. For most purposes, the default capture mode will suffice. - -Mouse capture is provided using the parameter `--mouse` (`-e`). It uses pynput for capturing mouse input and transmits this over the serial link simultaneously to keyboard input. Appropriate system permissions (Privacy and Security) may be required to use mouse capture. - -Video capture is provided using the parameter `--video` (`-x`). It uses OpenCV for capturing frames from the camera device. Again, system permissions for webcam access may need to be granted. - -See [Keyboard Modes](MODES.md) for more information on the various other options to the script. -Implementations are provided for all the main python input capture methods. - -## Troubleshooting - -**Permissions errors on Linux**: -if your system user does not have serial write permissions (resulting in a permission error), you can add your user to the `dialout` group: e.g. `sudo usermod -a -G dialout $USER`. You must fully log out of the system to apply the change. - -**Difficulty installing requirements**: If you get `command not found: pip` or similar when installing requirements, try: `python -m pip [...]` to run pip instead. - -## Acknowledgements -With thanks to [@beijixiaohu](https://github.com/beijixiaohu), the author of the [ch9329Comm PyPi package](https://pypi.org/project/ch9329Comm/) and [GitHub repo](https://github.com/beijixiaohu/CH9329_COMM/) (in Chinese), some code of which is re-used under the MIT License. - -Thank you, once again, to everyone who has [contributed](CONTRIBUTING.md) to this project. - -## License -(c) 2023-25 Samantha Finnigan and contributors (except where acknowledged) and released under [MIT License](https://github.com/sjmf/kvm-serial/blob/main/LICENSE.md). diff --git a/kvm_serial/backend/implementations/baseop.py b/kvm_serial/backend/implementations/baseop.py index 9e989b9..69be8da 100644 --- a/kvm_serial/backend/implementations/baseop.py +++ b/kvm_serial/backend/implementations/baseop.py @@ -6,7 +6,10 @@ class BaseOp(ABC): """ Abstract base class for input capture implementations. - All implementations must provide a run() method that takes a serial_port argument. + + All implementations must provide a run() method. The shared DataComm + instance is fetched from the DataCommManager singleton; lifecycle + (start/stop) is the manager's responsibility, not the op's. """ serial_port: Serial @@ -15,14 +18,19 @@ class BaseOp(ABC): def __init__(self, serial_port: Serial, layout: str = "en_GB"): """ - Initialize the operation with the given serial port, and - establish DataComm class for ch9329 communication + Initialise the operation, binding the shared protocol implementation + from the active DataCommManager. + :param serial_port: The serial port to communicate with. :param layout: Keyboard layout to use (default: 'en_GB') """ + # Import locally to avoid a circular import at module load time + # (manager -> keyboard -> backend.implementations.* -> baseop). + from kvm_serial.backend.manager import DataCommManager + self.serial_port = serial_port - self.hid_serial_out = DataComm(self.serial_port) self.layout = layout + self.hid_serial_out = DataCommManager.get().comm @abstractmethod def run(self): @@ -40,5 +48,7 @@ def name(self) -> str: def cleanup(self): """ - Optional cleanup method for implementations that need it. + Hook for implementation-specific cleanup. The shared comm's + lifecycle is owned by DataCommManager.stop(), not by individual + BaseOps. """ diff --git a/kvm_serial/backend/implementations/mouseop.py b/kvm_serial/backend/implementations/mouseop.py index 0ce86d9..4ea14de 100644 --- a/kvm_serial/backend/implementations/mouseop.py +++ b/kvm_serial/backend/implementations/mouseop.py @@ -22,6 +22,15 @@ class MouseOp(BaseOp): Mouse operation mode: handle mouse movement """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # Bitmask of currently-held mouse buttons. Updated by on_click and + # carried through on_move/on_scroll so that drags (button held while + # moving) preserve the held button on the target. Without this every + # move event during a drag would clear the button bit and the target + # would see release-on-first-motion. + self._buttons = 0 + @property def name(self): return "mouse" @@ -30,47 +39,33 @@ def run(self): raise Exception("Run not supported for MouseOp mode. Call from handler class") def on_move(self, x, y, width, height): - # Prepare data payload - data = bytearray(b"\x02\x00") # Absolute coordinates (0x02); No mouse buttons (0x0) - - # Scale coordinates to device range - dx = int((4096 * x) // width) - dy = int((4096 * y) // height) - - # Handle negative coordinates (e.g., dual monitor setups) - if dx < 0: - dx = abs(4096 + dx) - if dy < 0: - dy = abs(4096 + dy) - - data += dx.to_bytes(2, "little") - data += dy.to_bytes(2, "little") - - # Ensure data is exactly 7 bytes for abs move - data = data[:7] if len(data) > 7 else data.ljust(7, b"\x00") - - self.hid_serial_out.send(data, cmd=b"\x04") - logging.debug(f"Mouse moved to ({x}, {y})") + # Carry the current held-button state so drags work. + self.hid_serial_out.send_mouse_absolute(self._buttons, x, y, width, height) + logging.debug(f"Mouse moved to ({x}, {y}) buttons={self._buttons:#x}") return True def on_click(self, x, y, button: MouseButton, down): - data = bytearray(b"\x01") # Relative coordinates (0x01) - data += button.value if down else b"\x00" # Mouse button - data += b"\x00\x00" # Rel. mouse position x/y coordinate (2 bytes 0x0) - data += b"\x00" # pad to length 5 - - self.hid_serial_out.send(data, cmd=b"\x05") - - logging.debug(f"Mouse click at ({x}, {y}) with {button} (down={down})") + # Update held-button bitmask: set on press, clear on release. Other + # buttons remain held -- supports e.g. middle-click held while + # left-click is pressed. + bit = button.value[0] + if down: + self._buttons |= bit + else: + self._buttons &= ~bit & 0xFF + # Click events ride the relative-mouse path with zero motion deltas. + self.hid_serial_out.send_mouse_relative(self._buttons, 0, 0, 0) + logging.debug( + f"Mouse click at ({x}, {y}) with {button} (down={down}) " + f"-> buttons={self._buttons:#x}" + ) return True # Suppress the click event (pynput) def on_scroll(self, x, y, dx, dy): - data = bytearray(b"\x01") # Relative coordinates (0x01) - data += dx.to_bytes(2, "big", signed=True) - data += dy.to_bytes(2, "big", signed=True) - - self.hid_serial_out.send(data, cmd=b"\x05") - - logging.debug(f"Mouse scroll ({x}, {y}, {dx}, {dy})") + # CH9329 has a single wheel axis (vertical); horizontal dx is dropped. + # Clamping happens in the comm layer. Carry held-button state so that + # button-held-while-scrolling is preserved (uncommon but valid). + self.hid_serial_out.send_mouse_relative(self._buttons, 0, 0, int(dy)) + logging.debug(f"Mouse scroll ({x}, {y}, {dx}, {dy}) buttons={self._buttons:#x}") return True diff --git a/kvm_serial/backend/manager.py b/kvm_serial/backend/manager.py new file mode 100644 index 0000000..924ae76 --- /dev/null +++ b/kvm_serial/backend/manager.py @@ -0,0 +1,115 @@ +""" +DataCommManager: lifecycle owner for the protocol comm and the input +listeners attached to it. + +The manager exists because a single serial port can drive at most one +comm instance — CH9350L state 0/1 in particular spawns rx + tx-maintenance +threads, so two MouseOp/KeyboardOp ops each instantiating their own +CH9350Comm would race for the UART, run the descriptor handshake twice, +and emit interleaved frames with desynchronised counters. + +Singleton-of-one: ``DataCommManager(serial_port, comm_cls=...)`` constructs +the comm and registers itself; ``DataCommManager.get()`` returns the same +instance from anywhere (typically from BaseOp.__init__, which fetches the +shared comm). ``reset()`` is the test escape hatch. +""" + +from __future__ import annotations + +import logging +from typing import Callable, ClassVar + +from serial import Serial + +from kvm_serial.backend.inputhandler import InputHandler +from kvm_serial.utils.communication import DataComm + +logger = logging.getLogger(__name__) + +CommCls = Callable[[Serial], DataComm] + + +class DataCommManager: + """ + Singleton owning the DataComm instance and the input listeners + attached to it. Centralises start/join/stop so individual ops and + listeners don't manage lifecycle. + """ + + _instance: ClassVar["DataCommManager | None"] = None + + def __init__(self, serial_port: Serial, comm_cls: CommCls): + if DataCommManager._instance is not None: + raise RuntimeError( + "DataCommManager already initialised; call reset() first or " + "use DataCommManager.get() to retrieve the existing instance" + ) + self.serial_port = serial_port + self.comm: DataComm = comm_cls(serial_port) + self._listeners: list[InputHandler] = [] + DataCommManager._instance = self + + @classmethod + def get(cls) -> "DataCommManager": + """Return the singleton. Raises if no manager has been initialised.""" + if cls._instance is None: + raise RuntimeError( + "DataCommManager not initialised; construct one before " "instantiating any BaseOp" + ) + return cls._instance + + @classmethod + def reset(cls) -> None: + """ + Test hook: drop the singleton so the next constructor call succeeds. + Stops the comm and any attached listeners first, best-effort, so + background threads don't leak across tests. + """ + if cls._instance is None: + return + try: + cls._instance.stop() + except Exception: + logger.exception("DataCommManager.reset(): stop() raised, ignoring") + cls._instance = None + + def attach(self, listener: InputHandler) -> None: + """Register an input listener. Listeners are started in attach order.""" + self._listeners.append(listener) + + def start(self) -> None: + """Start the comm (handshake / threads) then every attached listener.""" + self.comm.start() + for listener in self._listeners: + listener.start() + + def join(self) -> None: + """ + Block on the primary listener's thread. The keyboard listener owns + the exit key (e.g. Ctrl+ESC) when present; otherwise fall back to + the first attached listener. + """ + # Avoid a circular import: KeyboardListener imports manager indirectly. + from kvm_serial.backend.keyboard import KeyboardListener + + primary = next( + (l for l in self._listeners if isinstance(l, KeyboardListener)), + self._listeners[0] if self._listeners else None, + ) + if primary is None: + return + thread = getattr(primary, "thread", None) + if thread is not None and hasattr(thread, "join"): + thread.join() + + def stop(self) -> None: + """Stop every listener (best-effort) then the comm.""" + for listener in self._listeners: + try: + listener.stop() + except Exception: + logger.exception("Listener stop() raised, continuing") + try: + self.comm.stop() + except Exception: + logger.exception("Comm stop() raised") diff --git a/kvm_serial/control.py b/kvm_serial/control.py index a8e8a61..e1f9e45 100755 --- a/kvm_serial/control.py +++ b/kvm_serial/control.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -# CH9329 keyboard controller +# Serial KVM controller for USB HID Serial Bridges (CH9329, CH9350L) import signal import sys import argparse @@ -7,23 +7,20 @@ import platform # Allow running as a script directly (python kvm_serial/control.py) by ensuring -# the project root is on sys.path so that `kvm_serial.*` imports resolve. -try: - from importlib import import_module - - import_module("kvm_serial.backend") -except ModuleNotFoundError: +# the project root is first on sys.path so local `kvm_serial.*` imports resolve. +if __name__ == "__main__" and (__package__ is None or __package__ == ""): import os - sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + if project_root not in sys.path: + sys.path.insert(0, project_root) from serial import Serial logger = logging.getLogger(__name__) -# Globally visible listener objects for thread stop -ml = None -keeb = None +# The single DataCommManager for this run; stop_threads() joins it. +mgr = None # Provide different options for handling SIGINT so Ctrl+C can be passed to controller @@ -38,60 +35,74 @@ def signal_handler_ignore(sig, frame): logging.debug("Ignoring Ctrl+C") +def _build_comm_cls(args): + """ + Resolve --ch9350 / --ch9350-state into a DataComm-producing callable. + Defaults to CH9329Comm (the class itself, which is callable). + """ + if args.ch9350: + from kvm_serial.utils.ch9350 import CH9350Comm + + state = args.ch9350_state + return lambda port: CH9350Comm(port, state=state) + from kvm_serial.utils.ch9329 import CH9329Comm + + return CH9329Comm + + def start_threads(args, serial_port): - """Start handler threads based on command line arguments. + """Construct the DataCommManager, attach listeners per CLI flags, and + start everything. Args: args: Parsed command line arguments. serial_port: Serial port object for communication. """ - global ml, keeb + global mgr + from kvm_serial.backend.manager import DataCommManager + + mgr = DataCommManager(serial_port, comm_cls=_build_comm_cls(args)) - # Start mouse listner on --mouse (-e) + # Start mouse listener on --mouse (-e) if args.mouse: from kvm_serial.backend.mouse import MouseListener - ml = MouseListener(serial_port) - ml.start() + mgr.attach(MouseListener(serial_port)) # Do not capture keyboard with --no-keyboard (-n) if not args.no_keyboard: from kvm_serial.backend.keyboard import KeyboardListener - keeb = KeyboardListener(serial_port, mode=args.mode, layout=args.keyboard_layout) - keeb.start() + mgr.attach(KeyboardListener(serial_port, mode=args.mode, layout=args.keyboard_layout)) + mgr.start() -def join_threads(args): - global ml, keeb - # Wait for threads to finish. The main thread depends on which inputs are active. - if (args.mode == "none" or args.no_keyboard) and ml is not None: - # Mouse-only: Ctrl+C raises KeyboardInterrupt and exits. - logging.info("Waiting for mouse listener...") - ml.thread.join() - elif not args.no_keyboard and keeb is not None: - # Keyboard listener owns the exit key (e.g. Ctrl+ESC). - logging.info("Waiting for keyboard listener...") - keeb.thread.join() +def join_threads(): + if mgr is not None: + mgr.join() def stop_threads(): - global ml, keeb - if ml is not None and ml.thread.is_alive(): - ml.stop() + global mgr + if mgr is not None: + mgr.stop() + # Drop the singleton so a subsequent run (e.g. from tests) can + # build a fresh manager without tripping the "already initialised" + # guard. + from kvm_serial.backend.manager import DataCommManager - if keeb is not None and keeb.thread.is_alive(): - keeb.stop() + DataCommManager.reset() + mgr = None def parse_args(): # Parse arguments using argparse module. Example call: # python control.py /dev/cu.usbserial --verbose --mode usb parser = argparse.ArgumentParser( - prog="CH9329 Control Script", - description="Use a serial terminal as a USB keyboard!", - epilog="(c) 2023 Samantha Finnigan. MIT License", + prog="Serial KVM Control Script", + description="Use a serial terminal as a USB keyboard and mouse!", + epilog="(c) 2023-25 Samantha Finnigan and contributors. MIT License", ) parser.add_argument("-v", "--verbose", action="store_true") @@ -140,6 +151,26 @@ def parse_args(): help="Capture mouse input", action="store_true", ) + proto_group = parser.add_mutually_exclusive_group() + proto_group.add_argument( + "--ch9329", + help="Use the CH9329 protocol (default; flag for imperative declaration)", + action="store_true", + ) + proto_group.add_argument( + "--ch9350", + help="Use the CH9350L extender protocol", + action="store_true", + ) + parser.add_argument( + "--ch9350-state", + help="CH9350L working state to drive (default 2: legacy BIOS / UEFI CSM " + "compatible). 0 = paired-mode descriptor handshake; 3 = absolute mouse; " + "4 = HID Digitizers. See docs/CH9350L_PROTO.md.", + type=int, + default=2, + choices=[0, 2, 3, 4], + ) vids_group = parser.add_argument_group( "Video Options (removed)", description="Headless video display has been removed. Switches accepted " @@ -195,7 +226,7 @@ def main(): try: start_threads(args, serial_port) - join_threads(args) + join_threads() except KeyboardInterrupt: logging.warning("^C caught. Cleaning up!") except Exception as e: diff --git a/kvm_serial/kvm.py b/kvm_serial/kvm.py index dd4c7c8..b955e02 100755 --- a/kvm_serial/kvm.py +++ b/kvm_serial/kvm.py @@ -4,7 +4,18 @@ import logging import time import math -from typing import cast, Optional +from typing import TYPE_CHECKING, cast, Optional + +if TYPE_CHECKING: + from kvm_serial.backend.manager import DataCommManager + +# Allow running as a script directly (python kvm_serial/kvm.py) by ensuring +# the project root is first on sys.path so local `kvm_serial.*` imports resolve. +if __name__ == "__main__" and (__package__ is None or __package__ == ""): + project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) + if project_root not in sys.path: + sys.path.insert(0, project_root) + from serial import Serial, SerialException from PyQt5.QtCore import Qt, QTimer, QSizeF, QRectF, QEvent, QLocale, pyqtSignal from PyQt5.QtGui import ( @@ -34,23 +45,12 @@ QSizePolicy, ) -try: - import kvm_serial.utils.settings as settings_util - from kvm_serial.utils.communication import list_serial_ports - from kvm_serial.utils import scancode_to_ascii, string_to_scancodes - from kvm_serial.backend.video import CameraProperties, enumerate_cameras - from kvm_serial.backend.implementations.qtop import QtOp - from kvm_serial.backend.implementations.mouseop import MouseOp, MouseButton - -except ModuleNotFoundError: - # Allow running as a script directly - sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) - import utils.settings as settings_util - from utils.communication import list_serial_ports - from utils import scancode_to_ascii, string_to_scancodes - from backend.video import CameraProperties, enumerate_cameras - from backend.implementations.qtop import QtOp - from backend.implementations.mouseop import MouseOp, MouseButton +import kvm_serial.utils.settings as settings_util +from kvm_serial.utils.communication import list_serial_ports +from kvm_serial.utils import scancode_to_ascii, string_to_scancodes +from kvm_serial.backend.video import CameraProperties, enumerate_cameras +from kvm_serial.backend.implementations.qtop import QtOp +from kvm_serial.backend.implementations.mouseop import MouseOp, MouseButton # Subclass QGraphicsView so clicks inside the view can receive focus and @@ -124,7 +124,8 @@ class KVMQtGui(QMainWindow): """ Main GUI class for the Serial KVM application (Qt version). - A graphical user interface (GUI) for controlling a CH9329-based software KVM (Keyboard, Video, Mouse) switch. + A graphical user interface (GUI) for controlling software KVM (Keyboard, Video, Mouse) switches + using CH9329 or CH9350L UART-to-USB-HID bridge chips. Provides a PyQt5-based interface for configuring and controlling serial, video, keyboard, and mouse devices. Handles device selection, status display, event processing, and persistent @@ -147,6 +148,11 @@ class KVMQtGui(QMainWindow): video_device_var: str = "Loading cameras..." keyboard_layout_var: str = "en_GB" resolution_var: str = "" # "WIDTHxHEIGHT", empty means auto (from camera enumeration) + # Protocol selection: "ch9329" (default) or "ch9350" with a working state + # in {0, 2, 3, 4}. State 0 enters paired mode (descriptor handshake); + # 2/3/4 are the dipswitch-fixed simple modes. + protocol_var: str = "ch9329" + ch9350_state_var: int = 2 window_var: bool = False show_status_var: bool = True @@ -166,6 +172,9 @@ class KVMQtGui(QMainWindow): serial_port: Serial | None = None keyboard_op: QtOp | None = None mouse_op: MouseOp | None = None + # The DataCommManager owning the active comm + its lifecycle. Reset + # alongside the serial port whenever __init_serial reopens the link. + comm_manager: "DataCommManager | None" = None # Dimensions window_default_width: int = 1280 @@ -285,12 +294,13 @@ def __init_menu(self): options_menu = menubar.addMenu("Options") options_menu = cast(QMenu, options_menu) # hush PyLance - # Serial Port, Baud, Video, Resolution, and Keyboard Layout submenus + # Serial Port, Baud, Video, Resolution, Keyboard Layout, Protocol submenus self.serial_port_menu = options_menu.addMenu("Serial Port") self.baud_rate_menu = options_menu.addMenu("Baud Rate") self.video_device_menu = options_menu.addMenu("Video Device") self.resolution_menu = options_menu.addMenu("Resolution") self.keyboard_layout_menu = options_menu.addMenu("Keyboard Layout") + self.protocol_menu = options_menu.addMenu("Protocol") options_menu.addSeparator() @@ -469,6 +479,7 @@ def __init_devices(self): self._populate_baud_rates() self._populate_video_devices() self._populate_keyboard_layouts() + self._populate_protocol_menu() QTimer.singleShot(10, lambda: self._load_settings(self.CONFIG_FILE)) def _update_status_bar(self): @@ -596,6 +607,29 @@ def _load_settings(self, config_file: str): self.keyboard_layout_var = self._detect_system_keyboard_layout() logging.info(f"Auto-detected keyboard layout: {self.keyboard_layout_var}") + # Load protocol selection (default CH9329 if missing or invalid) + saved_protocol = kvm.get("protocol", "ch9329") + if saved_protocol == "ch9350": + try: + saved_state = int(kvm.get("ch9350_state", "2")) + except ValueError: + saved_state = 2 + if saved_state in (0, 2, 3, 4): + self.protocol_var = "ch9350" + self.ch9350_state_var = saved_state + else: + logging.warning( + f"Invalid ch9350_state in settings: {kvm.get('ch9350_state')}; " + "falling back to CH9329" + ) + self.protocol_var = "ch9329" + else: + self.protocol_var = "ch9329" + if hasattr(self, "protocol_menu") and self.protocol_menu is not None: + target = self._protocol_label(self.protocol_var, self.ch9350_state_var) + for action in self.protocol_menu.actions(): + action.setChecked(action.text() == target) + # Apply mouse cursor state if needed if hasattr(self, "video_view"): if self.hide_mouse_var: @@ -689,6 +723,8 @@ def _save_settings(self): "verbose": str(self.verbose_var), "hide_mouse": str(self.hide_mouse_var), "keyboard_layout": str(self.keyboard_layout_var), + "protocol": self.protocol_var, + "ch9350_state": str(self.ch9350_state_var), } settings_util.save_settings(self.CONFIG_FILE, "KVM", settings_dict) logging.info("Settings saved to INI file.") @@ -814,6 +850,59 @@ def _populate_keyboard_layouts(self): if layout == self.keyboard_layout_var: action.setChecked(True) + # The full set of options the Protocol submenu offers. Each entry is + # (label, protocol, ch9350_state). State is unused for CH9329 but kept + # here for menu uniformity. + _PROTOCOL_OPTIONS = [ + ("CH9329", "ch9329", -1), + ("CH9350L (state 0/1, paired)", "ch9350", 0), + ("CH9350L (state 2, BIOS)", "ch9350", 2), + ("CH9350L (state 3, abs mouse)", "ch9350", 3), + ("CH9350L (state 4, Digitizers)", "ch9350", 4), + ] + + def _protocol_label(self, protocol: str, state: int) -> str: + """Return the menu label corresponding to (protocol, state).""" + for label, p, s in self._PROTOCOL_OPTIONS: + if p == protocol and (p == "ch9329" or s == state): + return label + return self._PROTOCOL_OPTIONS[0][0] # fall back to CH9329 + + def _populate_protocol_menu(self): + """ + Populate the Protocol submenu with checkable items for CH9329 and + the four CH9350L working modes. + """ + if self.protocol_menu is None: + raise TypeError("Initialise protocol_menu before calling _populate_protocol_menu()") + + self.protocol_menu.clear() + current = self._protocol_label(self.protocol_var, self.ch9350_state_var) + for label, protocol, state in self._PROTOCOL_OPTIONS: + action = QAction(label, self) + action.setCheckable(True) + action.triggered.connect( + lambda checked, p=protocol, s=state: self._on_protocol_selected(p, s) + ) + self.protocol_menu.addAction(action) + if label == current: + action.setChecked(True) + + def _on_protocol_selected(self, protocol: str, state: int): + """Handle selection of a protocol/state from the Protocol submenu.""" + if self.protocol_menu is None: + raise TypeError("Initialise protocol_menu before calling _on_protocol_selected()") + + target_label = self._protocol_label(protocol, state) + for action in self.protocol_menu.actions(): + action.setChecked(action.text() == target_label) + + self.protocol_var = protocol + if protocol == "ch9350": + self.ch9350_state_var = state + logging.info(f"Selected protocol: {target_label}") + self.__init_serial() + def _on_keyboard_layout_selected(self, layout): """ Handle selection of a keyboard layout. @@ -831,11 +920,41 @@ def _on_keyboard_layout_selected(self, layout): logging.info(f"Selected keyboard layout: {layout}") self.__init_serial() + def _build_comm_cls(self): + """ + Resolve the active protocol/state into a DataComm-producing callable + for DataCommManager to instantiate. CH9329Comm is callable directly; + CH9350Comm needs the chosen state bound via a lambda. + """ + if self.protocol_var == "ch9350": + from kvm_serial.utils.ch9350 import CH9350Comm + + state = self.ch9350_state_var + return lambda port: CH9350Comm(port, state=state) + + from kvm_serial.utils.ch9329 import CH9329Comm + + return CH9329Comm + + def _stop_comm_manager(self): + """Stop and discard the active DataCommManager, if any.""" + from kvm_serial.backend.manager import DataCommManager + + if self.comm_manager is not None: + try: + self.comm_manager.stop() + except Exception as e: + logging.error(f"Error stopping DataCommManager: {e}") + self.comm_manager = None + DataCommManager.reset() + def __init_serial(self): """ - Initialise or reinitialise serial port and keyboard/mouse operations. + Initialise or reinitialise serial port, DataCommManager, and the + keyboard/mouse operations bound to the shared comm. """ - # Close existing serial connection if open + # Stop the active manager (if any) before closing the underlying port. + self._stop_comm_manager() self._close_serial_port() # Clear existing operations @@ -856,6 +975,20 @@ def __init_serial(self): f"Opened serial port {self.serial_port_var} at {self.baud_rate_var} baud" ) + # Construct the DataCommManager for this port. Ops fetch the + # shared comm via DataCommManager.get(); the manager owns + # comm lifecycle (start/stop) so individual ops don't. + from kvm_serial.backend.manager import DataCommManager + + self.comm_manager = DataCommManager( + self.serial_port, comm_cls=self._build_comm_cls() + ) + self.comm_manager.start() + logging.info( + f"DataCommManager started: protocol={self.protocol_var}" + + (f" (state {self.ch9350_state_var})" if self.protocol_var == "ch9350" else "") + ) + # Initialise keyboard and mouse operations self.keyboard_op = QtOp(self.serial_port, layout=self.keyboard_layout_var) self.mouse_op = MouseOp(self.serial_port) @@ -867,6 +1000,7 @@ def __init_serial(self): self, "Serial Error", f"Failed to open serial port {self.serial_port_var}:\n{e}" ) # Reset to None if initialisation failed + self._stop_comm_manager() self.serial_port = None self.keyboard_op = None self.mouse_op = None @@ -1670,6 +1804,10 @@ def closeEvent(self, event): logging.debug(f"Error stopping QCamera on close: {e}") self.qcamera = None + # Stop the DataCommManager (background threads, descriptor handshake + # for CH9350 state 0/1) before closing the underlying port. + self._stop_comm_manager() + # Close serial port if open self._close_serial_port() diff --git a/kvm_serial/utils/ch9329.py b/kvm_serial/utils/ch9329.py new file mode 100644 index 0000000..348c94d --- /dev/null +++ b/kvm_serial/utils/ch9329.py @@ -0,0 +1,131 @@ +""" +CH9329 UART-to-USB-HID bridge protocol implementation. + +The CH9329 is a host-driven, command-style chip: this module sends framed +commands over the serial port and the chip translates them into USB HID +reports on the target side. Frame layout: + + header(2B: 0x57 0xAB) + addr(1B) + cmd(1B) + len(1B) + data + checksum(1B) +""" + +from kvm_serial.utils.communication import DataComm + + +class CH9329Comm(DataComm): + """ + CH9329 UART-to-USB-HID bridge. + + Originally derived from beijixiaohu/ch9329Comm: + https://github.com/beijixiaohu/CH9329_COMM/ + """ + + def send( + self, + data: bytes, + head: bytes = b"\x57\xab", + addr: bytes = b"\x00", + cmd: bytes = b"\x02", + ) -> bool: + """ + Build a CH9329 data packet and write it to the serial port. + + Args: + data: payload bytes to encapsulate and send + head: Packet header (2 bytes) + addr: address byte + cmd: Data command byte (0x02 = Keyboard; 0x04 = Absolute mouse; 0x05 = Relative mouse) Returns: + Returns: + True if successful, otherwise throws an exception + """ + # Check inputs + if len(head) != 2 or len(addr) != 1 or len(cmd) != 1: + raise ValueError("CH9329 packet header MUST have: header 2b; addr 1b; cmd 1b") + + length = len(data).to_bytes(1, "little") + + # Calculate checksum + checksum = ( + sum(head) + + int.from_bytes(addr, "big") + + int.from_bytes(cmd, "big") + + int.from_bytes(length, "big") + + sum(data) + ) % 256 + + # Build data packet + packet = head + addr + cmd + length + data + bytes([checksum]) + + # Write command to serial port + self.port.write(packet) + + return True + + def send_scancode(self, scancode: bytes) -> bool: + """ + Send an 8-byte HID keyboard scancode. + Does additional length checking and returns False if long + + Args: + scancode: An 8-byte scancode representing keyboard state + Returns: + bool: True if successful, False otherwise + """ + if len(scancode) < self.SCANCODE_LENGTH: + return False + + return self.send(scancode) + + def release(self): + """ + Sends the all-zeros scancode (release all keys). + + Return: + bool: True if successful + """ + return self.send(b"\x00" * self.SCANCODE_LENGTH) + + def send_mouse_absolute( + self, buttons: int, x: int, y: int, width: int, height: int, wheel: int = 0 + ) -> bool: + """ + Build and send a CH9329 absolute mouse report (cmd=0x04). + Wire payload (7 bytes): direction(0x02) + buttons + xL xH + yL yH + wheel. + Source x/y are scaled into the chip's 12-bit absolute space (0..4095). + """ + # Scale source coordinates into CH9329's 12-bit absolute space. + dx = int((4096 * x) // max(1, width)) + dy = int((4096 * y) // max(1, height)) + + # Wrap negatives (e.g. multi-monitor setups where x or y can be < 0). + if dx < 0: + dx = abs(4096 + dx) + if dy < 0: + dy = abs(4096 + dy) + + data = bytearray(b"\x02") # absolute-coordinate marker + data += bytes([buttons & 0xFF]) + + data += dx.to_bytes(2, "little") + data += dy.to_bytes(2, "little") + + data += _signed_byte(wheel) + return self.send(bytes(data), cmd=b"\x04") + + def send_mouse_relative(self, buttons: int, dx: int, dy: int, wheel: int = 0) -> bool: + """ + Build and send a CH9329 relative mouse report (cmd=0x05). + Wire payload (5 bytes): direction(0x01) + buttons + dx + dy + wheel. + dx, dy, wheel are 1-byte signed values (-127..+127). + """ + data = bytearray(b"\x01") # relative-coordinate marker + data += bytes([buttons & 0xFF]) + data += _signed_byte(dx) + data += _signed_byte(dy) + data += _signed_byte(wheel) + return self.send(bytes(data), cmd=b"\x05") + + +def _signed_byte(value: int) -> bytes: + """Clamp an int to the signed 8-bit range and return its 1-byte encoding.""" + clamped = max(-127, min(127, value)) + return clamped.to_bytes(1, "big", signed=True) diff --git a/kvm_serial/utils/ch9350.py b/kvm_serial/utils/ch9350.py new file mode 100644 index 0000000..602b0be --- /dev/null +++ b/kvm_serial/utils/ch9350.py @@ -0,0 +1,723 @@ +""" +CH9350L UART-to-USB-HID extender protocol implementation. + +The CH9350L is a paired-chip extender: a *lower computer* (LC, USB host; +reads keyboard/mouse) talks to an *upper computer* (UC, USB device; presents +HID to the target PC) over UART. kvm-serial drives the UART side, replacing +the LC in software, so the CH9350L on the other end of the link enumerates +USB HID devices on the target. + +This module covers all four working states (selected on the UC's S0/S1 +dipswitches): + + state 0/1: paired mode with descriptor announce + PID-ack handshake + — full HID Report Descriptor passthrough + state 2: BIOS keyboard + relative mouse + — legacy BIOS / UEFI CSM compatible + state 3: BIOS keyboard + absolute mouse + — modern OS, single monitor + state 4: BIOS keyboard + absolute mouse + HID Digitizers + — modern OS, multi-monitor + +State 0/1 is a single user choice (the chip boots into state 0 and +transitions to state 1 internally once the UC has acknowledged every +announced PID). States 2/3/4 are dipswitch-fixed and need no handshake. +See docs/CH9350L_PROTO.md for the on-the-wire framing detail. +""" + +import logging +import threading +import time + +from kvm_serial.utils.communication import DataComm + +logger = logging.getLogger(__name__) + +HEADER = b"\x57\xab" + +# LC -> UC heartbeat. Low nibble = IO pin state; a real LC sends 0xA3 +# (IO0/IO1 high). The UC also accepts 0xA0. +HEARTBEAT_FRAME = HEADER + bytes([0x82, 0xA3]) + +# Captured HID Report Descriptors from a real CH9350L LC. The LC announces +# these over 0x81 frames so the UC can present matching descriptors to the +# target. PIDs are reflected back in the UC's 0x12 keep-alive once each +# descriptor has been processed; the LC waits for that ack before +# transitioning state 0 -> state 1. +DEFAULT_MOUSE_DESC = bytes.fromhex( + # ---- Relative mouse application ---- + "05010902a101" # Usage Page (Generic Desktop), Usage (Mouse), Collection (Application) + "8501" # Report ID 1 + "0901a100" # Usage (Pointer), Collection (Physical) + "0509190129031500250175019503810275059501" # 5 buttons + X/Y relative + wheel + "810105010930093109381581257f750895038106c0" # Extended features + "05ff09021500250175019501b12275079501b101c0" # Vendor-specific +) +DEFAULT_MOUSE_PID = bytes.fromhex("4000") +DEFAULT_KBD_DESC = bytes.fromhex( + # ---- Keyboard application ---- + "05010906a101" # Generic Desktop / Keyboard + "8501050719e029e7150025017501950881029501750881019503750105081901" # Report ID 1: modifiers + keycodes + "2903910295017505910195067508150026ff000507190029918100c0" + # ---- System Control application ---- + "05010980a101" # Generic Desktop / System Control + "850219812983150025019503750181029501750581" + "01c0" + # ---- Consumer Control application ---- + "050c0901a101" # Consumer / Consumer Control + "85031500250109e909ea09e209cd19b529b87501950881020a8a010a21" + "020a2a021a23022a270281020a83010a96010a92010a9e010a94010a060209b2" + "09b48102c0" +) +DEFAULT_KBD_PID = bytes.fromhex("0315") + +# Known fixed payload lengths (bytes after the cmd byte) for UC -> LC +# decode. 0x83/0x88 are length-prefixed and 0x81 has its own 2-byte LE +# length field; both are handled separately in _parse_frames. +_PAYLOAD_LEN = { + 0x82: 1, # heartbeat (IO status byte) + 0x12: 8, # UC keep-alive (8 bytes after cmd) + 0x01: 8, # state-2 keyboard (8-byte HID boot report) + 0x02: 4, # state-2 relative mouse (btn, dx, dy, wheel) + 0x04: 7, # state-3/4 absolute mouse (id, btn, xL, xH, yL, yH, wheel) + 0x80: 1, # LED status / "unknown" (0xFF or 0x3N) + 0x86: 0, # device-notify (no payload) + 0x89: 0, # status announce (no payload) +} + + +def _split_relative_delta(dx: int, dy: int, max_step: int = 127): + """ + Yield (chunk_dx, chunk_dy) tuples, each within ±max_step, summing to (dx, dy). + + State-0/1/2 mouse frames carry signed-byte deltas; a single send for + |delta| > 127 would clamp at the comm layer and lose displacement. Splitting + proportionally lets a single absolute target translate to a short burst of + relative frames that the host integrates back into the requested motion -- + same approach as Wacom's on-device pen-to-mouse conversion. Frames-per-axis + is ceil(max(|dx|, |dy|) / max_step); each yielded chunk respects max_step + by construction. + """ + if abs(dx) <= max_step and abs(dy) <= max_step: + yield dx, dy + return + n = (max(abs(dx), abs(dy)) - 1) // max_step + 1 + sent_dx = sent_dy = 0 + for i in range(1, n + 1): + target_dx = dx * i // n + target_dy = dy * i // n + yield target_dx - sent_dx, target_dy - sent_dy + sent_dx, sent_dy = target_dx, target_dy + + +def _parse_frames(buf: bytearray) -> tuple[list[tuple[int, bytes]], bytearray]: + """ + Extract complete frames from buf. Returns (frames, remaining_buf) where + frames is a list of (cmd, payload) tuples. cmd == -1 represents bytes + skipped while resyncing on HEADER (logged as a hint that something is + off in the receive stream). + """ + frames: list[tuple[int, bytes]] = [] + while True: + idx = buf.find(HEADER) + if idx == -1: + # No header in buf: keep the last byte in case it starts a header. + buf = bytearray(buf[-1:]) if buf else bytearray() + break + if idx > 0: + # Pre-header bytes are framing noise (mid-frame sniff start, a + # corrupted byte, etc.); surface them so the caller can log. + skipped = bytes(buf[:idx]) + buf = buf[idx:] + frames.append((-1, skipped)) + + if len(buf) < 3: + break # need at least header + cmd + + cmd = buf[2] + + if cmd in (0x83, 0x88): + # Length-prefixed: payload[0] = LEN, total payload = 1 + LEN bytes. + if len(buf) < 4: + break + plen = buf[3] + needed = 4 + plen + if len(buf) < needed: + break + payload = bytes(buf[3:needed]) + buf = buf[needed:] + frames.append((cmd, payload)) + continue + + if cmd == 0x81: + # Device Connection: 57 AB 81 [PORT:1] [LEN:2 LE] [PAYLOAD] [PID:2] [CHK:1] + if len(buf) < 6: + break + plen = buf[4] | (buf[5] << 8) + needed = 9 + plen # 3 (hdr+cmd) + 1 (PORT) + 2 (LEN) + plen + 2 (PID) + 1 (CHK) + if len(buf) < needed: + break + payload = bytes(buf[3:needed]) + buf = buf[needed:] + frames.append((cmd, payload)) + continue + + known_len = _PAYLOAD_LEN.get(cmd) + if known_len is not None: + needed = 3 + known_len + if len(buf) < needed: + break + payload = bytes(buf[3:needed]) + buf = buf[needed:] + frames.append((cmd, payload)) + else: + # Unknown cmd: scan for next header to bound the frame. + next_hdr = buf.find(HEADER, 3) + if next_hdr == -1: + if len(buf) > 128: + payload = bytes(buf[3:]) + frames.append((cmd, payload)) + buf = bytearray() + else: + break + else: + payload = bytes(buf[3:next_hdr]) + frames.append((cmd, payload)) + buf = buf[next_hdr:] + + return frames, buf + + +class CH9350Comm(DataComm): + """ + CH9350L extender — supports all four working states. + + State 0/1 (paired mode) requires a full attach handshake against a real + UC: send 0x86 -> 0x80 x2 -> 0x89, wait for the UC's 0x12 keep-alive, then + announce the LC's HID Report Descriptors via 0x81 xN. The state machine + transitions 0 -> 1 once every announced PID is reflected back in the + UC's keep-alive. Frames after that point use cmd 0x83 (paired); before + it, cmd 0x88 (unpaired) — payloads are otherwise identical. + + States 2/3/4 are dipswitch-fixed simple modes with no handshake; LC->UC + frames carry no SER, no checksum, no per-frame counter. + + For state 0/1 callers must invoke ``start()`` to spawn the rx and + maintenance threads and run the attach sequence; ``stop()`` halts them. + States 2/3/4 don't need start/stop — those calls are no-ops there. + """ + + STATE_0 = 0 + STATE_1 = 1 + STATE_2 = 2 + STATE_3 = 3 + STATE_4 = 4 + + # State 1 is reached internally via the state-0 handshake; not user-selectable. + SUPPORTED_STATES = (STATE_0, STATE_2, STATE_3, STATE_4) + + # State-0/1 frame SER + report-ID bytes. These must agree with the + # announced descriptors' Report ID items — RID values prefix each + # 0x83/0x88 payload. + KB_SER = 0x13 # keyboard / HID / port 2 + KB_RID = 0x01 # 8-byte boot keyboard report follows + MOU_SER = 0x22 # mouse / HID / port 1 + MOU_RID = 0x01 # 4-byte relative mouse report follows + + # State-3/4 absolute coordinate range. Empirically calibrated by + # sweeping scene-x across a 1920x1080 target and recording cursor + # landing position: cursor sweeps fully on nx 0..1023, then clamps to + # the left edge for nx 1024..2047, sweeps again on nx 2048..3071, etc. + # Pattern is consistent with a host descriptor declaring an 11-bit + # signed field (LogicalMin=-1024, LogicalMax=+1023, RepSize=11) with + # bit 10 as the sign bit; values with bit 10 set are interpreted as + # negative, off-screen-left, and clamped to the LogicalMin. Bits 11+ + # are silently ignored, producing the period-2048 wrap. Effective + # usable range from the LC is 0..1023. + # + # The X/Y wire fields are 16-bit LE regardless; the upper 6 bits are + # unused / Const padding per the chip's descriptor. + _ABS_MAX = 0x03FF + + # How long to wait between 0x81 retransmits while a PID remains un-acked. + _ANNOUNCE_RETRY_INTERVAL = 2.0 + _HEARTBEAT_INTERVAL = 1.0 + + def __init__( + self, + port, + state: int = STATE_2, + mouse_desc: bytes | None = None, + mouse_pid: bytes | None = None, + kbd_desc: bytes | None = None, + kbd_pid: bytes | None = None, + ): + if state not in self.SUPPORTED_STATES: + raise ValueError( + f"CH9350Comm supports states {self.SUPPORTED_STATES}; " + f"state 1 is reached internally via the state-0 handshake " + f"and is not user-selectable" + ) + super().__init__(port) + self.state = state + # Cursor-position bookkeeping. State 3/4 needs the last absolute + # position so that button/scroll events (which arrive via + # send_mouse_relative with zero motion) can re-emit it with the new + # button/wheel state. State 2 uses the previous position to translate + # send_mouse_absolute calls into on-the-wire relative deltas. + self._last_x = 0 + self._last_y = 0 + self._last_width = 1 + self._last_height = 1 + + # State 0/1 plumbing — irrelevant for states 2/3/4 but cheap to set + # up unconditionally and keeps the attribute access uniform. + self.mouse_desc = mouse_desc if mouse_desc is not None else DEFAULT_MOUSE_DESC + self.kbd_desc = kbd_desc if kbd_desc is not None else DEFAULT_KBD_DESC + self.mouse_pid = mouse_pid if mouse_pid is not None else DEFAULT_MOUSE_PID + self.kbd_pid = kbd_pid if kbd_pid is not None else DEFAULT_KBD_PID + if len(self.mouse_pid) != 2 or len(self.kbd_pid) != 2: + raise ValueError("mouse_pid and kbd_pid must each be exactly 2 bytes") + self._kbd_counter = 0 + self._mou_counter = 0 + self._uc_p1 = b"\x00\x00" + self._uc_p2 = b"\x00\x00" + self._uc_status = 0x00 + self._last_hb = 0.0 + self._last_mouse_announce = 0.0 + self._last_kbd_announce = 0.0 + self._stop = threading.Event() + self._uc_seen = threading.Event() + self._reattach_needed = threading.Event() + # Last LED byte echoed back to the UC via 0x80 0x3N (states 2/3/4). + # Sentinel 0xFF means "nothing echoed yet"; the UC's pre-enum default + # is also 0xFF and we deliberately do not echo that. + self._echoed_led = 0xFF + # Serialise port writes — rx callbacks (LED echo), tx maintenance + # (heartbeat / retransmit), and user sends all converge on port.write. + self._tx_lock = threading.Lock() + self._rx_thread: threading.Thread | None = None + self._tx_thread: threading.Thread | None = None + + # ------------------------------------------------------------------ public + + def start(self) -> None: + """ + Spawn the rx thread, plus (state 0 only) the tx-maintenance thread + that drives the attach handshake, heartbeats, and reattach replay. + + State 0: rx + tx-maintenance. + States 2/3/4: rx only - there's no handshake, but the rx loop drives + LED echo back to the source keyboard from the UC's 0x12 + keep-alive. + """ + if self._rx_thread is not None: + raise RuntimeError("CH9350Comm.start() called twice") + self._stop.clear() + self._rx_thread = threading.Thread(target=self._rx_loop, daemon=True) + self._rx_thread.start() + if self.state == self.STATE_0: + # Run the initial attach in the maintenance thread so start() + # returns immediately; the caller is then free to send frames + # (which will emit 0x88 unpaired until the UC acks all PIDs and + # we transition to state 1). + self._tx_thread = threading.Thread(target=self._tx_maint_loop, daemon=True) + self._tx_thread.start() + + def stop(self) -> None: + """Halt the rx (and, in state 0, tx-maintenance) threads.""" + self._stop.set() + if self._tx_thread is not None: + self._tx_thread.join(timeout=2.0) + self._tx_thread = None + if self._rx_thread is not None: + self._rx_thread.join(timeout=2.0) + self._rx_thread = None + + def send_scancode(self, scancode: bytes) -> bool: + """ + Send an 8-byte HID boot-protocol keyboard report. + State 0/1 wraps it in a SER + counter framed payload (cmd 0x83/0x88); + states 2/3/4 use the simple 11-byte 0x01 frame. + """ + if len(scancode) < self.SCANCODE_LENGTH: + return False + if self.state in (self.STATE_0, self.STATE_1): + modifier = scancode[0] + keys = list(scancode[2 : self.SCANCODE_LENGTH]) + frame = self._build_state01_kbd_frame(modifier, keys) + return self._send_locked(frame) + self._send_locked(HEADER + b"\x01" + scancode[: self.SCANCODE_LENGTH]) + return True + + def release(self) -> bool: + """Release all keys (send the all-zeros HID report).""" + return self.send_scancode(b"\x00" * self.SCANCODE_LENGTH) + + def send_mouse_absolute( + self, buttons: int, x: int, y: int, width: int, height: int, wheel: int = 0 + ) -> bool: + """ + Emit a positional update. + + States 3/4: native absolute path. Pixel coords scale into the chip's + 16-bit absolute space and emit a 0x04 frame. + States 0/1 and 2: no absolute path exists on the wire (UC firmware + silently drops LEN=0x0A in state 0/1; state 2's BIOS boot mouse is + relative-only by design — see docs/CH9350L_PROTO.md §Divergences). + Translate to a relative delta from the last reported absolute + position. When |delta| > 127 in either axis (e.g. cursor teleport, + focus-into-window from far away) the displacement fans out into a + short burst of consecutive frames so the cursor reaches the true + target rather than stopping ~127 px short — equivalent to what a + Wacom-style device does on-chip in pen-to-mouse mode. + """ + if self.state in (self.STATE_0, self.STATE_1, self.STATE_2): + dx = int(x - self._last_x) + dy = int(y - self._last_y) + logger.debug( + "mouse_abs->rel state=%d xy=(%s,%s) wh=(%s,%s) last=(%s,%s) dxdy=(%d,%d)", + self.state, + x, + y, + width, + height, + self._last_x, + self._last_y, + dx, + dy, + ) + self._last_x, self._last_y = x, y + self._last_width, self._last_height = width, height + ok = True + for i, (chunk_dx, chunk_dy) in enumerate(_split_relative_delta(dx, dy)): + # Wheel rides only the first chunk so a multi-frame fan-out + # doesn't multiply a single scroll request. + chunk_wheel = wheel if i == 0 else 0 + ok = self._send_relative_frame(buttons, chunk_dx, chunk_dy, chunk_wheel) and ok + return ok + + nx = max(0, min(self._ABS_MAX, int(((self._ABS_MAX + 1) * x) // max(1, width)))) + ny = max(0, min(self._ABS_MAX, int(((self._ABS_MAX + 1) * y) // max(1, height)))) + logger.debug( + "mouse_abs->native state=%d xy=(%s,%s) wh=(%s,%s) -> nxny=(%d,%d) of max=%d", + self.state, + x, + y, + width, + height, + nx, + ny, + self._ABS_MAX, + ) + self._last_x, self._last_y = x, y + self._last_width, self._last_height = width, height + return self._send_absolute_frame(buttons, nx, ny, wheel) + + @property + def supports_absolute_mouse(self) -> bool: + """ + True if this comm forwards absolute-mouse reports natively to the + target host (states 3/4); False if `send_mouse_absolute` translates + the requested position into relative deltas (states 0/1/2). Useful + for application code surfacing mode information or selecting + different UX for absolute-aware vs relative-only modes. + """ + return self.state in (self.STATE_3, self.STATE_4) + + def send_mouse_relative(self, buttons: int, dx: int, dy: int, wheel: int = 0) -> bool: + """ + Emit a button or scroll event (or, in state 0/1/2, a true relative motion). + + States 0/1 and 2: emit a native relative-mouse frame. + States 3/4: re-emit the last known absolute position with the new + button/wheel bytes — there's no relative path on the wire, so + button-only and scroll events ride the absolute frame. + """ + if self.state in (self.STATE_0, self.STATE_1, self.STATE_2): + return self._send_relative_frame(buttons, dx, dy, wheel) + + nx = max( + 0, + min( + self._ABS_MAX, int(((self._ABS_MAX + 1) * self._last_x) // max(1, self._last_width)) + ), + ) + ny = max( + 0, + min( + self._ABS_MAX, + int(((self._ABS_MAX + 1) * self._last_y) // max(1, self._last_height)), + ), + ) + return self._send_absolute_frame(buttons, nx, ny, wheel) + + # ------------------------------------------------------------- frame builders + + def _send_relative_frame(self, buttons: int, dx: int, dy: int, wheel: int) -> bool: + if self.state in (self.STATE_0, self.STATE_1): + frame = self._build_state01_mou_frame(buttons, dx, dy, wheel) + else: + # State 2 simple frame: 7 bytes total. + dx_b = max(-127, min(127, int(dx))) & 0xFF + dy_b = max(-127, min(127, int(dy))) & 0xFF + wh_b = max(-127, min(127, int(wheel))) & 0xFF + frame = HEADER + bytes([0x02, buttons & 0xFF, dx_b, dy_b, wh_b]) + return self._send_locked(frame) + + def _send_absolute_frame(self, buttons: int, x: int, y: int, wheel: int) -> bool: + # x and y are 12-bit values (0..4095) carried in 16-bit LE fields with + # the upper 4 bits unused / Const padding per the chip's built-in + # absolute descriptor. The leading 0x01 after the cmd byte is a fixed + # report-ID prefix matching empirical captures from real LCs. + wh_b = max(-127, min(127, wheel)) & 0xFF + frame = HEADER + bytes( + [ + 0x04, + 0x01, + buttons & 0xFF, + x & 0xFF, + (x >> 8) & 0xFF, + y & 0xFF, + (y >> 8) & 0xFF, + wh_b, + ] + ) + return self._send_locked(frame) + + def _build_state01_kbd_frame(self, modifier: int, keycodes: list[int]) -> bytes: + """ + State-0/1 keyboard frame: + HEADER + cmd + LEN + SER + RID + [mod rsvd k0..k5] + CTR + CTRSUM + cmd is 0x88 in state 0 (unpaired) or 0x83 in state 1 (paired). + CTR is a per-SER counter (mod-256) and CTRSUM = (CTR + sum(hid)) & 0xFF. + """ + cmd = 0x83 if self.state == self.STATE_1 else 0x88 + keys = (keycodes + [0] * 6)[:6] + hid = bytes([self.KB_RID, modifier & 0xFF, 0x00] + keys) + ctr = self._kbd_counter & 0xFF + self._kbd_counter += 1 + ctr_sum = (ctr + sum(hid)) & 0xFF + plen = 1 + len(hid) + 2 # SER + hid + CTR + CTRSUM + return HEADER + bytes([cmd, plen, self.KB_SER]) + hid + bytes([ctr, ctr_sum]) + + def _build_state01_mou_frame(self, btn: int, dx: int, dy: int, wheel: int) -> bytes: + """ + State-0/1 relative mouse frame: + HEADER + cmd + LEN + SER + RID + [btn dx dy wheel] + CTR + CTRSUM + cmd is 0x88 (state 0) or 0x83 (state 1). dx/dy/wheel clamp to ±127. + """ + cmd = 0x83 if self.state == self.STATE_1 else 0x88 + dx_b = max(-127, min(127, int(dx))) & 0xFF + dy_b = max(-127, min(127, int(dy))) & 0xFF + wh_b = max(-127, min(127, int(wheel))) & 0xFF + data = bytes([self.MOU_RID, btn & 0xFF, dx_b, dy_b, wh_b]) + ctr = self._mou_counter & 0xFF + self._mou_counter += 1 + ctr_sum = (ctr + sum(data)) & 0xFF + plen = 1 + len(data) + 2 + return HEADER + bytes([cmd, plen, self.MOU_SER]) + data + bytes([ctr, ctr_sum]) + + def _build_device_connect_frame( + self, descriptor: bytes, port_id: int, device_pid: bytes + ) -> bytes: + """ + Build a 0x81 Device Connection Frame: + HEADER + 0x81 + [PORT:1] + [LEN:2 LE] + DESCRIPTOR + [PID:2] + [CHK:1] + CHK = (sum(DESCRIPTOR) + sum(PID)) & 0xFF. + """ + if len(device_pid) != 2: + raise ValueError("device_pid must be 2 bytes") + plen = len(descriptor) + chk = (sum(descriptor) + sum(device_pid)) & 0xFF + return ( + HEADER + + bytes([0x81, port_id, plen & 0xFF, (plen >> 8) & 0xFF]) + + descriptor + + device_pid + + bytes([chk]) + ) + + # ------------------------------------------------------------------ tx helpers + + def _send_locked(self, data: bytes) -> bool: + """Serialised port write — used by user sends, rx callbacks, and the + tx maintenance thread.""" + with self._tx_lock: + self.port.write(data) + return True + + def _heartbeat(self) -> None: + self._send_locked(HEARTBEAT_FRAME) + self._last_hb = time.time() + + def _send_announce(self) -> None: + """Send a 0x89 status announce. Real LCs emit ~3 of these during + attach; the LC sends one as part of the attach sequence here.""" + self._send_locked(HEADER + bytes([0x89])) + + def _announce_descriptors(self) -> None: + """Send 0x81 Device Connection Frames for each configured device.""" + now = time.time() + if self.mouse_desc: + self._send_locked( + self._build_device_connect_frame( + self.mouse_desc, port_id=0x00, device_pid=self.mouse_pid + ) + ) + self._last_mouse_announce = now + time.sleep(0.05) + if self.kbd_desc: + self._send_locked( + self._build_device_connect_frame( + self.kbd_desc, port_id=0x01, device_pid=self.kbd_pid + ) + ) + self._last_kbd_announce = now + time.sleep(0.05) + + def _maybe_retransmit_descriptors(self, now: float) -> None: + """Retransmit any 0x81 whose PID isn't yet reflected in the UC's + keep-alive. Recovers from UC restart or transient UART loss.""" + if self.mouse_desc and self._uc_p1 != self.mouse_pid: + if now - self._last_mouse_announce >= self._ANNOUNCE_RETRY_INTERVAL: + self._send_locked( + self._build_device_connect_frame( + self.mouse_desc, port_id=0x00, device_pid=self.mouse_pid + ) + ) + self._last_mouse_announce = now + if self.kbd_desc and self._uc_p2 != self.kbd_pid: + if now - self._last_kbd_announce >= self._ANNOUNCE_RETRY_INTERVAL: + self._send_locked( + self._build_device_connect_frame( + self.kbd_desc, port_id=0x01, device_pid=self.kbd_pid + ) + ) + self._last_kbd_announce = now + + def _run_attach_sequence(self, *, wait_for_uc: bool) -> None: + """ + Replay the LC->UC attach sequence: + 0x86 -> 0x80 0xFF (x2) -> heartbeat -> 0x89 -> 0x81 xN + Used at startup (wait_for_uc=True: defer 0x81 until a UC keep-alive + is observed) and on reconnect (wait_for_uc=False: UC is already + keep-aliving but its USB-device side dropped and needs 0x86/0x80 to + re-present to the target). + """ + self._send_locked(HEADER + bytes([0x86])) + time.sleep(0.25) + self._send_locked(HEADER + bytes([0x80, 0xFF])) + time.sleep(0.23) + self._send_locked(HEADER + bytes([0x80, 0xFF])) + self._heartbeat() + time.sleep(1.0) + self._send_announce() + + if not (self.mouse_desc or self.kbd_desc): + return + if wait_for_uc and not self._uc_seen.is_set(): + logger.info("Waiting for UC keep-alive before announcing devices...") + while not self._uc_seen.wait(timeout=self._HEARTBEAT_INTERVAL): + if self._stop.is_set(): + return + self._heartbeat() + self._announce_descriptors() + + # ---------------------------------------------------------- background loops + + def _tx_maint_loop(self) -> None: + """ + Heartbeat, 0x81 retransmit, and full attach replay on reconnect. + Decoupled from user sends so a slow caller doesn't starve the UC of + heartbeats or stall reconnect handling. + """ + self._run_attach_sequence(wait_for_uc=True) + while not self._stop.is_set(): + if self._reattach_needed.is_set(): + self._reattach_needed.clear() + logger.info("Reattach: replaying attach sequence") + self._run_attach_sequence(wait_for_uc=False) + continue + now = time.time() + if now - self._last_hb >= self._HEARTBEAT_INTERVAL: + self._heartbeat() + self._maybe_retransmit_descriptors(now) + self._stop.wait(timeout=0.2) + + def _rx_loop(self) -> None: + """Read + decode incoming frames from upper computer.""" + buf = bytearray() + while not self._stop.is_set(): + try: + chunk = self.port.read(64) + except Exception: + # Serial errors during shutdown are expected; fall through + # to the stop check on the next iteration. + break + if chunk: + buf.extend(chunk) + frames, buf = _parse_frames(buf) + for cmd, payload in frames: + if cmd == -1: + logger.debug("rx sync skip: %d bytes", len(payload)) + continue + self._handle_frame(cmd, payload) + + def _handle_frame(self, cmd: int, payload: bytes) -> None: + """ + Receive-side state machine. Pure function modulo ``self``: tests + can drive it directly with synthetic UC frames. + + Decodes the UC's 0x12 keep-alive (PIDs in P1/P2, LED byte at index 4, + STATUS at index 5) and dispatches: + - State 0: sets _uc_seen on first 0x12 (cleared to send 0x81), + transitions to state 1 once every announced PID is reflected. + - State 1: reverts to state 0 + sets _reattach_needed when the UC + drops a previously-acked PID (target-side USB replug). + - States 2/3/4: echoes the UC's reported LED state back as a + 0x80 0x3N frame so the source keyboard's lock-key LEDs mirror + the target host. Echoes only on change to avoid flooding. + """ + logger.debug("rx cmd=%02x payload=%s", cmd, payload.hex(" ")) + if cmd != 0x12 or len(payload) < 4: + return + self._uc_p1 = bytes(payload[0:2]) + self._uc_p2 = bytes(payload[2:4]) + if len(payload) >= 6: + self._uc_status = payload[5] + if not self._uc_seen.is_set(): + logger.info("UC keep-alive observed -- announce phase cleared") + self._uc_seen.set() + + # LED echo for the simple modes. The UC's 0x12 byte 4 carries the + # target host's keyboard LED state (NumLk/CapsLk/ScrLk in the low + # three bits); we mirror it back to the LC's USB-host side via + # 0x80 0x3N so the source keyboard's lock LEDs follow the target. + # 0xFF means "UC has no target enumerated yet"; never echo that. + if self.state in (self.STATE_2, self.STATE_3, self.STATE_4) and len(payload) >= 5: + led_byte = payload[4] + if led_byte != 0xFF and led_byte != self._echoed_led: + self._send_locked(HEADER + bytes([0x80, 0x30 | (led_byte & 0x07)])) + self._echoed_led = led_byte + return + + want_p1 = self.mouse_pid if self.mouse_desc else b"\x00\x00" + want_p2 = self.kbd_pid if self.kbd_desc else b"\x00\x00" + any_announced = bool(self.mouse_desc or self.kbd_desc) + all_acked = self._uc_p1 == want_p1 and self._uc_p2 == want_p2 + + if self.state == self.STATE_0 and any_announced and all_acked: + logger.info("UC acknowledged all PIDs - entering state 1") + self.state = self.STATE_1 + elif self.state == self.STATE_1 and not all_acked: + # UC dropped a PID - likely target-side USB replug. Revert to + # state 0 and trigger the maintenance thread to replay the full + # attach sequence (0x81 alone won't make the UC re-enumerate). + logger.info( + "UC PIDs no longer match (p1=%s p2=%s) - reverting to state 0, triggering reattach", + self._uc_p1.hex(), + self._uc_p2.hex(), + ) + self.state = self.STATE_0 + self._reattach_needed.set() diff --git a/kvm_serial/utils/communication.py b/kvm_serial/utils/communication.py index f231bd9..22b66bc 100644 --- a/kvm_serial/utils/communication.py +++ b/kvm_serial/utils/communication.py @@ -1,5 +1,6 @@ import sys import logging +from abc import ABC, abstractmethod from serial import Serial, SerialException from serial.tools import list_ports @@ -10,10 +11,15 @@ termios = None -class DataComm: +class DataComm(ABC): """ - DataComm class based on beijixiaohu/ch9329Comm module; simplified and commented - Original: https://github.com/beijixiaohu/CH9329_COMM/ / https://pypi.org/project/ch9329Comm/ + Abstract base for protocol implementations that drive a UART-to-USB-HID + bridge chip from kvm-serial. Subclasses implement the wire framing for a + specific chip family (CH9329, CH9350L, ...). + + Callers interact through the high-level methods below; concrete subclasses + handle packet construction and any chip-specific state (pairing handshake, + descriptor announce, working-state selection, etc.). """ SCANCODE_LENGTH = 8 @@ -21,70 +27,45 @@ class DataComm: def __init__(self, port: Serial): self.port = port - def send( - self, - data: bytes, - head: bytes = b"\x57\xab", - addr: bytes = b"\x00", - cmd: bytes = b"\x02", - ) -> bool: - """ - Convert input to data packet and send command over serial. - - Args: - data: data packet to encapsulate and send - head: Packet header - addr: Address - cmd: Data command (0x02 = Keyboard; 0x04 = Absolute mouse; 0x05 = Relative mouse) - Returns: - True if successful, otherwise throws an exception - """ - # Check inputs - if len(head) != 2 or len(addr) != 1 or len(cmd) != 1: - raise ValueError("DataComm packet header MUST have: header 2b; addr 1b; cmd 1b") - - length = len(data).to_bytes(1, "little") + @abstractmethod + def send_scancode(self, scancode: bytes) -> bool: + """Send an 8-byte USB HID boot-protocol keyboard report.""" - # Calculate checksum - checksum = ( - sum(head) - + int.from_bytes(addr, "big") - + int.from_bytes(cmd, "big") - + int.from_bytes(length, "big") - + sum(data) - ) % 256 + @abstractmethod + def release(self) -> bool: + """Send an empty (all keys released) keyboard report.""" - # Build data packet - packet = head + addr + cmd + length + data + bytes([checksum]) + @abstractmethod + def send_mouse_absolute( + self, buttons: int, x: int, y: int, width: int, height: int, wheel: int = 0 + ) -> bool: + """Send an absolute-positioning mouse report. - # Write command to serial port - self.port.write(packet) + x, y are in source pixel coordinates; width, height bound the source + surface so subclasses can scale into the chip's native coordinate + space (CH9329: 0..4095; CH9350 state 3/4: 0..0x3FF). + """ - return True + @abstractmethod + def send_mouse_relative(self, buttons: int, dx: int, dy: int, wheel: int = 0) -> bool: + """Send a relative-motion mouse report. - def send_scancode(self, scancode: bytes) -> bool: + dx, dy, wheel are signed 8-bit deltas (-127..+127); callers pass 0 for + unused fields (e.g. button-only events or pure scroll events). """ - Send function for use with scancodes - Does additional length checking and returns False if long - Args: - scancode: An 8-byte scancode representing keyboard state - Returns: - bool: True if successful, False otherwise + def start(self) -> None: """ - if len(scancode) < self.SCANCODE_LENGTH: - return False - - return self.send(scancode) - - def release(self): + Optional lifecycle hook invoked once the comm is wired into a BaseOp. + Implementations that need background threads or a handshake (e.g. + CH9350Comm state 0) override this; default is a no-op. """ - Release the button. - Return: - bool: True if successful + def stop(self) -> None: + """ + Optional lifecycle hook invoked at shutdown. Pairs with start(); + default is a no-op. """ - return self.send(b"\x00" * self.SCANCODE_LENGTH) def list_serial_ports(): diff --git a/mkdocs.yml b/mkdocs.yml index 85e999b..26e585f 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -24,6 +24,7 @@ theme: nav: - Home: index.md - Installation: INSTALLATION.md + - Supported Devices: SUPPORTED_DEVICES.md - Keyboard Modes: MODES.md - Building: BUILD.md - Testing: TESTING.md diff --git a/pyproject.toml b/pyproject.toml index c598101..6f9aa8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,9 +4,9 @@ build-backend = "setuptools.build_meta" [project] name = "kvm-serial" -version = "1.5.5" +version = "1.5.6" authors = [{ name="Samantha Finnigan", email="1038320+sjmf@users.noreply.github.com" }] -description = "Python package for interfacing with CH9329 KVM devices" +description = "Python package for controlling remote computers using UART-to-USB-HID bridge chips" readme = "README.md" requires-python = ">=3.10" diff --git a/scripts/generate_docs_index.py b/scripts/generate_docs_index.py new file mode 100644 index 0000000..0350b00 --- /dev/null +++ b/scripts/generate_docs_index.py @@ -0,0 +1,32 @@ +from pathlib import Path +import re + +ROOT = Path(__file__).resolve().parents[1] +README = ROOT / "README.md" +DOCS_INDEX = ROOT / "docs" / "index.md" +URL = "https://github.com/sjmf/kvm-serial" + + +def main() -> None: + content = README.read_text(encoding="utf-8") + + # Strip docs/ prefix from all markdown link paths: (docs/X) -> (X) + content = re.sub(r"\(docs/([^\)]+)\)", r"(\1)", content) + # Also handle ./docs/ paths: (./docs/X) -> (X) + content = re.sub(r"\(\./docs/([^\)]+)\)", r"(\1)", content) + + # External root-level docs need GitHub URLs + content = content.replace("(LICENSE.md)", f"({URL}/blob/main/LICENSE.md)") + content = content.replace("(CONTRIBUTING.md)", f"({URL}/blob/main/CONTRIBUTING.md)") + + # Asset URLs need to be absolute for docs site + content = content.replace( + 'src="assets/icon.png"', + 'src="https://raw.githubusercontent.com/sjmf/kvm-serial/main/assets/icon.png"', + ) + + DOCS_INDEX.write_text(content, encoding="utf-8") + + +if __name__ == "__main__": + main() diff --git a/tests/backend/implementations/conftest.py b/tests/backend/implementations/conftest.py new file mode 100644 index 0000000..b145173 --- /dev/null +++ b/tests/backend/implementations/conftest.py @@ -0,0 +1,36 @@ +""" +Per-directory pytest configuration for backend op tests. + +Op tests (PyUSBOp, TtyOp, CursesOp, PynputOp, MouseOp, ...) all instantiate +a BaseOp subclass, which fetches its DataComm from the active +DataCommManager singleton. This autouse fixture installs a fresh manager +wrapping a MagicMock comm so op constructors succeed without each test +having to wire one up by hand. + +The fixture deliberately lives in this subdirectory rather than the root +conftest: importing kvm_serial.backend.manager pre-loads kvm_serial.backend +into sys.modules, which interacts badly with patch.dict-based sys.modules +manipulation in tests that load other backend submodules inside a +patch.dict block (the patch evicts the submodule on exit, but the parent +package's stale attribute survives, leaving sys.modules and attribute-path +imports resolving to different module objects). Scoping the manager +import to the implementations/ subtree keeps the rest of tests/backend/ +unaffected. +""" + +from unittest.mock import MagicMock +import pytest + + +@pytest.fixture(autouse=True) +def _datacomm_manager(): + """Install a fresh DataCommManager singleton wrapping a MagicMock comm + for every op test in this subtree. Reset on teardown.""" + from kvm_serial.backend.manager import DataCommManager + + DataCommManager.reset() + mgr = DataCommManager(MagicMock(), comm_cls=lambda port: MagicMock()) + try: + yield mgr + finally: + DataCommManager.reset() diff --git a/tests/backend/implementations/test_baseop.py b/tests/backend/implementations/test_baseop.py index b1c1b4b..7145593 100644 --- a/tests/backend/implementations/test_baseop.py +++ b/tests/backend/implementations/test_baseop.py @@ -1,49 +1,72 @@ -from pytest import fixture from unittest.mock import patch, MagicMock from tests._utilities import MockSerial, mock_serial -CLASS_PATH = "kvm_serial.backend.implementations.baseop" +class TestBaseOpImplementation: + """Test suite for BaseOp base class implementation""" -@fixture -def sys_modules_patch(): - return { - "serial": MagicMock(), - } + def test_baseop_init_uses_manager_comm(self, mock_serial, _datacomm_manager): + """BaseOp fetches its DataComm from the active DataCommManager + rather than instantiating one itself.""" + from kvm_serial.backend.implementations.baseop import BaseOp + class ConcreteBaseOp(BaseOp): + def run(self): + return True -class TestBaseOpImplementation: - """Test suite for BaseOp base class implementation""" + @property + def name(self): + return "test" + + op = ConcreteBaseOp(mock_serial) + + assert op.serial_port is mock_serial + assert op.name == "test" + # The autouse _datacomm_manager fixture installs a singleton whose + # comm is a MagicMock; BaseOp should hand out exactly that instance. + assert op.hid_serial_out is _datacomm_manager.comm - def test_baseop_init(self, mock_serial, sys_modules_patch): + def test_baseop_init_raises_without_manager(self, mock_serial): + """Constructing a BaseOp with no manager initialised raises a + clear error rather than silently auto-creating a comm.""" + from kvm_serial.backend.implementations.baseop import BaseOp + from kvm_serial.backend.manager import DataCommManager - with ( - # I don't know why this makes test_mouse work, but at this point I'm too tired to care. - patch.dict("sys.modules", sys_modules_patch), - patch("kvm_serial.utils.communication.DataComm") as mock_datacomm, - ): - from kvm_serial.backend.implementations.baseop import BaseOp + class ConcreteBaseOp(BaseOp): + def run(self): + return True - class ConcreteBaseOp(BaseOp): - """Concrete implementation of BaseOp for testing""" + @property + def name(self): + return "test" - def run(self): - return True + # The autouse fixture has a manager set up; tear it down for this + # test to assert the no-manager error path. + DataCommManager.reset() + try: + import pytest - def cleanup(self): - pass + with pytest.raises(RuntimeError, match="not initialised"): + ConcreteBaseOp(mock_serial) + finally: + # Restore a fresh manager so the autouse fixture's teardown + # doesn't trip a stale singleton. + DataCommManager(MagicMock(), comm_cls=lambda port: MagicMock()) - @property - def name(self): - return "test_keyboard" + def test_baseop_cleanup_is_a_noop(self, mock_serial, _datacomm_manager): + """BaseOp.cleanup() no longer touches the comm (lifecycle is the + manager's responsibility); subclasses can override for their own + cleanup needs.""" + from kvm_serial.backend.implementations.baseop import BaseOp - # Create instance - op = ConcreteBaseOp(mock_serial) + class ConcreteBaseOp(BaseOp): + def run(self): + return True - # Verify initialization - assert op.serial_port == mock_serial - assert op.name == "test_keyboard" + @property + def name(self): + return "test" - # Verify DataComm was initialized with correct serial port - assert isinstance(op.hid_serial_out, MagicMock) - mock_datacomm.assert_called_once_with(mock_serial) + op = ConcreteBaseOp(mock_serial) + op.cleanup() + _datacomm_manager.comm.stop.assert_not_called() diff --git a/tests/backend/test_manager.py b/tests/backend/test_manager.py new file mode 100644 index 0000000..38279be --- /dev/null +++ b/tests/backend/test_manager.py @@ -0,0 +1,197 @@ +""" +Tests for DataCommManager — the singleton that owns the comm + listener +lifecycle for a kvm-serial run. +""" + +from unittest.mock import MagicMock, call +import pytest + +from kvm_serial.backend.manager import DataCommManager + + +@pytest.fixture(autouse=True) +def _reset_singleton(): + """Make sure no stale singleton from another test leaks into this file.""" + DataCommManager.reset() + yield + DataCommManager.reset() + + +def test_init_invokes_comm_cls_with_serial_port(): + """The comm_cls callable receives the serial port and the resulting + comm becomes the manager's ``comm`` attribute.""" + serial = MagicMock() + comm = MagicMock() + cls = MagicMock(return_value=comm) + + mgr = DataCommManager(serial, comm_cls=cls) + + cls.assert_called_once_with(serial) + assert mgr.comm is comm + assert mgr.serial_port is serial + + +def test_double_init_raises(): + """Attempting to construct a second manager without resetting raises.""" + DataCommManager(MagicMock(), comm_cls=lambda p: MagicMock()) + with pytest.raises(RuntimeError, match="already initialised"): + DataCommManager(MagicMock(), comm_cls=lambda p: MagicMock()) + + +def test_get_without_init_raises(): + """get() before any constructor surfaces a clear error rather than + silently auto-creating one.""" + with pytest.raises(RuntimeError, match="not initialised"): + DataCommManager.get() + + +def test_get_returns_singleton(): + """get() hands back the same instance on repeated calls.""" + mgr = DataCommManager(MagicMock(), comm_cls=lambda p: MagicMock()) + assert DataCommManager.get() is mgr + assert DataCommManager.get() is mgr + + +def test_reset_drops_singleton_and_stops_comm(): + """reset() stops the comm and clears the singleton so a fresh one can + be constructed.""" + comm = MagicMock() + DataCommManager(MagicMock(), comm_cls=lambda p: comm) + + DataCommManager.reset() + + comm.stop.assert_called_once() + with pytest.raises(RuntimeError): + DataCommManager.get() + + +def test_attach_registers_listeners_in_order(): + mgr = DataCommManager(MagicMock(), comm_cls=lambda p: MagicMock()) + a, b = MagicMock(), MagicMock() + + mgr.attach(a) + mgr.attach(b) + + assert mgr._listeners == [a, b] + + +def test_start_runs_comm_then_listeners(): + """start() calls comm.start() before any listener.start() so handshake + completes (CH9350 state 0) before user input can fire.""" + comm = MagicMock() + mgr = DataCommManager(MagicMock(), comm_cls=lambda p: comm) + a, b = MagicMock(), MagicMock() + mgr.attach(a) + mgr.attach(b) + + # Use an order-preserving manager mock to verify call sequencing. + order = MagicMock() + order.attach_mock(comm.start, "comm_start") + order.attach_mock(a.start, "a_start") + order.attach_mock(b.start, "b_start") + + mgr.start() + + assert order.mock_calls == [call.comm_start(), call.a_start(), call.b_start()] + + +def test_stop_runs_listeners_then_comm(): + """stop() halts listeners first then the comm, mirroring start().""" + comm = MagicMock() + mgr = DataCommManager(MagicMock(), comm_cls=lambda p: comm) + a, b = MagicMock(), MagicMock() + mgr.attach(a) + mgr.attach(b) + + order = MagicMock() + order.attach_mock(a.stop, "a_stop") + order.attach_mock(b.stop, "b_stop") + order.attach_mock(comm.stop, "comm_stop") + + mgr.stop() + + assert order.mock_calls == [call.a_stop(), call.b_stop(), call.comm_stop()] + + +def test_stop_continues_past_listener_exception(): + """A listener whose stop() raises shouldn't prevent the others from + being stopped, nor the comm.""" + comm = MagicMock() + mgr = DataCommManager(MagicMock(), comm_cls=lambda p: comm) + bad = MagicMock() + bad.stop.side_effect = RuntimeError("listener exploded") + good = MagicMock() + mgr.attach(bad) + mgr.attach(good) + + mgr.stop() + + bad.stop.assert_called_once() + good.stop.assert_called_once() + comm.stop.assert_called_once() + + +def test_join_blocks_on_keyboard_listener_when_present(): + """join() prefers the keyboard listener (it owns the exit key + Ctrl+ESC); falls back to the first attached otherwise.""" + from kvm_serial.backend.keyboard import KeyboardListener + + mgr = DataCommManager(MagicMock(), comm_cls=lambda p: MagicMock()) + + mouse_listener = MagicMock() + mouse_listener.thread = MagicMock() + keeb_listener = MagicMock(spec=KeyboardListener) + keeb_listener.thread = MagicMock() + + mgr.attach(mouse_listener) + mgr.attach(keeb_listener) + + mgr.join() + + keeb_listener.thread.join.assert_called_once() + mouse_listener.thread.join.assert_not_called() + + +def test_join_falls_back_to_first_listener(): + """No keyboard listener attached: join() blocks on the first one.""" + mgr = DataCommManager(MagicMock(), comm_cls=lambda p: MagicMock()) + + listener = MagicMock() + listener.thread = MagicMock() + mgr.attach(listener) + + mgr.join() + + listener.thread.join.assert_called_once() + + +def test_join_with_no_listeners_is_a_noop(): + """No listeners = nothing to wait on; join() returns silently.""" + mgr = DataCommManager(MagicMock(), comm_cls=lambda p: MagicMock()) + mgr.join() # should not raise + + +def test_reset_continues_when_stop_raises(): + """reset() swallows exceptions from stop() and still clears the singleton.""" + comm = MagicMock() + comm.stop.side_effect = RuntimeError("stop exploded") + DataCommManager(MagicMock(), comm_cls=lambda p: comm) + + DataCommManager.reset() # must not raise + + with pytest.raises(RuntimeError): + DataCommManager.get() + + +def test_stop_continues_when_comm_stop_raises(): + """Exceptions from comm.stop() are swallowed; listener shutdown still completes.""" + comm = MagicMock() + comm.stop.side_effect = RuntimeError("comm stop failed") + mgr = DataCommManager(MagicMock(), comm_cls=lambda p: comm) + listener = MagicMock() + mgr.attach(listener) + + mgr.stop() # must not raise + + listener.stop.assert_called_once() + comm.stop.assert_called_once() diff --git a/tests/backend/test_mouse.py b/tests/backend/test_mouse.py index d19b781..6578acf 100644 --- a/tests/backend/test_mouse.py +++ b/tests/backend/test_mouse.py @@ -27,18 +27,17 @@ def sys_modules_patch(): @patch("serial.Serial", MockSerial) class TestMouse: - def test_mouse_listener(self, mock_serial, sys_modules_patch): - """Test basic MouseListener initialization""" + def test_mouse_listener(self, mock_serial, sys_modules_patch, _datacomm_manager): + """MouseListener constructs a MouseOp that fetches the shared + comm from the active DataCommManager.""" with patch.dict("sys.modules", sys_modules_patch): - from kvm_serial.backend.implementations import baseop as baseop_mod from kvm_serial.backend.mouse import MouseListener - with patch.object(baseop_mod, "DataComm") as mock_datacomm: - MouseListener(mock_serial) - mock_datacomm.assert_called_once_with(mock_serial) + listener = MouseListener(mock_serial) + assert listener.op.hid_serial_out is _datacomm_manager.comm - def test_thread_calls(self, mock_serial, sys_modules_patch): + def test_thread_calls(self, mock_serial, sys_modules_patch, _datacomm_manager): """ Test that MouseListener.run(), start(), and stop() call correct Listener thread methods. Mocks: @@ -69,175 +68,162 @@ def test_thread_calls(self, mock_serial, sys_modules_patch): mock_thread.join.assert_called_once() mock_thread.reset_mock() - def test_on_move(self, mock_serial, sys_modules_patch): + def test_on_move(self, mock_serial, sys_modules_patch, _datacomm_manager): """ - Test MouseListener.on_move sends the correct data to comm.send and returns True. - Mocks: - - DataComm: To track send calls - Asserts: - - comm.send is called with the expected data and cmd - - on_move returns True + Test MouseListener.on_move forwards positional events to + send_mouse_absolute with the listener's screen dimensions and returns + True. Wire-level scaling and negative-coordinate wrapping live in + CH9329Comm — verified in tests/utils/test_ch9329.py. """ + with patch.dict("sys.modules", sys_modules_patch): + from kvm_serial.backend.mouse import MouseListener + + mock_comm = _datacomm_manager.comm + listener = MouseListener(mock_serial) + listener._width = 1920 + listener._height = 1080 - # Calculate expected dx, dy in a helper function - def calculate_expected_data(x, y, width, height): - dx = int((4096 * x) // width) - dy = int((4096 * y) // height) - if dx < 0: - dx = abs(4096 + dx) - if dy < 0: - dy = abs(4096 + dy) - expected_data = bytearray(b"\x02\x00") - expected_data += dx.to_bytes(2, "little") - expected_data += dy.to_bytes(2, "little") - return expected_data[:7] if len(expected_data) > 7 else expected_data.ljust(7, b"\x00") + result = listener.on_move(960, 540) + mock_comm.send_mouse_absolute.assert_called_once_with(0, 960, 540, 1920, 1080) + assert result is True + def test_on_click(self, mock_serial, sys_modules_patch, _datacomm_manager): + """ + Test MouseListener.on_click forwards press/release events to + send_mouse_relative with the right button mask and zero motion. + """ with patch.dict("sys.modules", sys_modules_patch): - from kvm_serial.backend.implementations import baseop as baseop_mod from kvm_serial.backend.mouse import MouseListener + from kvm_serial.backend.implementations.mouseop import MouseButton - with patch.object(baseop_mod, "DataComm") as mock_comm: - # Set up a MouseListener with known screen size - listener = MouseListener(mock_serial) - listener.op.hid_serial_out = mock_comm - listener._width = 1920 - listener._height = 1080 - - # Case 1: Center of the screen (positive dx/dy) - x, y = 960, 540 - result = listener.on_move(x, y) - expected_data = calculate_expected_data(x, y, listener._width, listener._height) - mock_comm.send.assert_called_once_with(expected_data, cmd=b"\x04") + mock_comm = _datacomm_manager.comm + listener = MouseListener(mock_serial) + listener._width = 1920 + listener._height = 1080 + + left_button = MagicMock() + right_button = MagicMock() + listener.pynput_button_mapping = { # type: ignore + left_button: MouseButton.LEFT, + right_button: MouseButton.RIGHT, + } + + # (button, down, expected_button_byte) + cases = [ + (left_button, True, 0x01), + (left_button, False, 0x00), + (right_button, True, 0x02), + (right_button, False, 0x00), + ] + for button, down, expected_byte in cases: + result = listener.on_click(100, 200, button, down) + mock_comm.send_mouse_relative.assert_called_once_with(expected_byte, 0, 0, 0) assert result is True - mock_comm.send.reset_mock() - - # Case 2: Negative dx (monitor to the left) - x_neg, y_neg = -100, 540 - result_neg = listener.on_move(x_neg, y_neg) - expected_data_neg = calculate_expected_data( - x_neg, y_neg, listener._width, listener._height - ) - mock_comm.send.assert_called_once_with(expected_data_neg, cmd=b"\x04") - assert result_neg is True - mock_comm.send.reset_mock() - - # Case 3: Negative dy (monitor above) - x_neg2, y_neg2 = 960, -100 - result_neg2 = listener.on_move(x_neg2, y_neg2) - expected_data_neg2 = calculate_expected_data( - x_neg2, y_neg2, listener._width, listener._height - ) - mock_comm.send.assert_called_once_with(expected_data_neg2, cmd=b"\x04") - assert result_neg2 is True - mock_comm.send.reset_mock() - - # Case 4: Both dx and dy negative (monitor above and left) - x_neg3, y_neg3 = -100, -100 - result_neg3 = listener.on_move(x_neg3, y_neg3) - expected_data_neg3 = calculate_expected_data( - x_neg3, y_neg3, listener._width, listener._height - ) - mock_comm.send.assert_called_once_with(expected_data_neg3, cmd=b"\x04") - assert result_neg3 is True - mock_comm.send.reset_mock() - - def test_on_click(self, mock_serial, sys_modules_patch): + mock_comm.send_mouse_relative.reset_mock() + + def test_drag_preserves_held_button(self, mock_serial, sys_modules_patch, _datacomm_manager): """ - Test MouseListener.on_click sends correct data for button press/release and returns True. - Mocks: - - DataComm: To track send calls - Asserts: - - comm.send is called with expected data and cmd for press and release - - on_click returns True + Regression: a drag is mouse-down → on_move(s) while held → mouse-up. + Previously on_move always sent buttons=0, so the very first move event + during a drag released the held button on the target. on_click must + update MouseOp._buttons and on_move must pass it through. + """ + with patch.dict("sys.modules", sys_modules_patch): + from kvm_serial.backend.mouse import MouseListener + from kvm_serial.backend.implementations.mouseop import MouseButton + + mock_comm = _datacomm_manager.comm + listener = MouseListener(mock_serial) + listener._width = 1920 + listener._height = 1080 + + left_button = MagicMock() + listener.pynput_button_mapping = {left_button: MouseButton.LEFT} # type: ignore + + # Press LEFT. + listener.on_click(100, 200, left_button, True) + mock_comm.send_mouse_relative.assert_called_once_with(0x01, 0, 0, 0) + mock_comm.reset_mock() + + # Move while held: button must still be set in the abs frame. + listener.on_move(150, 250) + mock_comm.send_mouse_absolute.assert_called_once_with(0x01, 150, 250, 1920, 1080) + mock_comm.reset_mock() + + # Another move while held. + listener.on_move(200, 300) + mock_comm.send_mouse_absolute.assert_called_once_with(0x01, 200, 300, 1920, 1080) + mock_comm.reset_mock() + + # Release LEFT: button bit clears. + listener.on_click(200, 300, left_button, False) + mock_comm.send_mouse_relative.assert_called_once_with(0x00, 0, 0, 0) + mock_comm.reset_mock() + + # Subsequent move with no buttons held: buttons=0. + listener.on_move(250, 350) + mock_comm.send_mouse_absolute.assert_called_once_with(0x00, 250, 350, 1920, 1080) + + def test_multi_button_held_independently( + self, mock_serial, sys_modules_patch, _datacomm_manager + ): + """ + Holding LEFT and pressing RIGHT (e.g. while LEFT is still down) + produces a combined bitmask 0x03; releasing one clears only that bit. """ with patch.dict("sys.modules", sys_modules_patch): - from kvm_serial.backend.implementations import baseop as baseop_mod from kvm_serial.backend.mouse import MouseListener from kvm_serial.backend.implementations.mouseop import MouseButton - with patch.object(baseop_mod, "DataComm") as mock_comm: - listener = MouseListener(mock_serial) - - listener.op.hid_serial_out = mock_comm - listener._width = 1920 - listener._height = 1080 - - # Mock button values - left_button = MagicMock() - right_button = MagicMock() - # Patch control_chars to accept our mock buttons - listener.pynput_button_mapping = { # type: ignore - left_button: MouseButton.LEFT, - right_button: MouseButton.RIGHT, - } - # Test left button press - result_press = listener.on_click(100, 200, left_button, True) - expected_data_press = bytearray(b"\x01\x01\x00\x00\x00") - mock_comm.send.assert_called_once_with(expected_data_press, cmd=b"\x05") - assert result_press is True - mock_comm.send.reset_mock() - # Test left button release - result_release = listener.on_click(100, 200, left_button, False) - expected_data_release = bytearray(b"\x01\x00\x00\x00\x00") - mock_comm.send.assert_called_once_with(expected_data_release, cmd=b"\x05") - assert result_release is True - mock_comm.send.reset_mock() - # Test right button press - result_press_r = listener.on_click(100, 200, right_button, True) - expected_data_press_r = bytearray(b"\x01\x02\x00\x00\x00") - mock_comm.send.assert_called_once_with(expected_data_press_r, cmd=b"\x05") - assert result_press_r is True - mock_comm.send.reset_mock() - # Test right button release - result_release_r = listener.on_click(100, 200, right_button, False) - expected_data_release_r = bytearray(b"\x01\x00\x00\x00\x00") - mock_comm.send.assert_called_once_with(expected_data_release_r, cmd=b"\x05") - assert result_release_r is True - mock_comm.send.reset_mock() - - def test_on_scroll(self, mock_serial, sys_modules_patch): + mock_comm = _datacomm_manager.comm + listener = MouseListener(mock_serial) + listener._width = 1920 + listener._height = 1080 + left = MagicMock() + right = MagicMock() + listener.pynput_button_mapping = { # type: ignore + left: MouseButton.LEFT, + right: MouseButton.RIGHT, + } + + listener.on_click(0, 0, left, True) # 0x01 + mock_comm.send_mouse_relative.assert_called_with(0x01, 0, 0, 0) + + listener.on_click(0, 0, right, True) # 0x01 | 0x02 = 0x03 + mock_comm.send_mouse_relative.assert_called_with(0x03, 0, 0, 0) + + listener.on_click(0, 0, left, False) # 0x03 & ~0x01 = 0x02 + mock_comm.send_mouse_relative.assert_called_with(0x02, 0, 0, 0) + + listener.on_click(0, 0, right, False) # 0x02 & ~0x02 = 0x00 + mock_comm.send_mouse_relative.assert_called_with(0x00, 0, 0, 0) + + def test_on_scroll(self, mock_serial, sys_modules_patch, _datacomm_manager): """ - Test MouseListener.on_scroll sends correct data for scroll events and returns True. - Mocks: - - DataComm: To track send calls - Asserts: - - comm.send is called with expected data and cmd for scroll up/down/left/right - - on_scroll returns True + Test MouseListener.on_scroll forwards vertical scroll deltas as the + wheel argument to send_mouse_relative; horizontal dx is dropped + (CH9329's relative-mouse frame has no horizontal wheel axis). """ with patch.dict("sys.modules", sys_modules_patch): - from kvm_serial.backend.implementations import baseop as baseop_mod from kvm_serial.backend.mouse import MouseListener - with patch.object(baseop_mod, "DataComm") as mock_comm: - listener = MouseListener(mock_serial) - listener.op.hid_serial_out = mock_comm - listener._width = 1920 - listener._height = 1080 - - # Test scroll up - result_up = listener.on_scroll(100, 200, 0, 1) - expected_data_up = bytearray(b"\x01\x00\x00\x00\x01") - mock_comm.send.assert_called_once_with(expected_data_up, cmd=b"\x05") - assert result_up is True - mock_comm.send.reset_mock() - # Test scroll down - result_down = listener.on_scroll(100, 200, 0, -1) - expected_data_down = bytearray(b"\x01\x00\x00\xff\xff") - mock_comm.send.assert_called_once_with(expected_data_down, cmd=b"\x05") - assert result_down is True - mock_comm.send.reset_mock() - # Test scroll right - result_right = listener.on_scroll(100, 200, 1, 0) - expected_data_right = bytearray(b"\x01\x00\x01\x00\x00") - mock_comm.send.assert_called_once_with(expected_data_right, cmd=b"\x05") - assert result_right is True - mock_comm.send.reset_mock() - # Test scroll left - result_left = listener.on_scroll(100, 200, -1, 0) - expected_data_left = bytearray(b"\x01\xff\xff\x00\x00") - mock_comm.send.assert_called_once_with(expected_data_left, cmd=b"\x05") - assert result_left is True - mock_comm.send.reset_mock() + mock_comm = _datacomm_manager.comm + listener = MouseListener(mock_serial) + listener._width = 1920 + listener._height = 1080 + + # (dx, dy, expected_wheel) — dx is intentionally discarded. + cases = [ + (0, 1, 1), + (0, -1, -1), + (1, 0, 0), + (-1, 0, 0), + ] + for dx, dy, expected_wheel in cases: + result = listener.on_scroll(100, 200, dx, dy) + mock_comm.send_mouse_relative.assert_called_once_with(0, 0, 0, expected_wheel) + assert result is True + mock_comm.send_mouse_relative.reset_mock() # --- diff --git a/tests/conftest.py b/tests/conftest.py index 8af0683..6f3ea45 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,8 @@ import sys from unittest.mock import MagicMock +import pytest + # Mock serial modules BEFORE any test imports happen def pytest_configure(config): @@ -31,3 +33,53 @@ def pytest_configure(config): sys.modules["serial"] = mock_serial_mod sys.modules["serial.tools"] = mock_tools sys.modules["serial.tools.list_ports"] = mock_list_ports + + # Mock usb / usb.core for pyusbop. USBError must be a real Exception + # class so the `except USBError as e:` clause inside pyusbop validates. + class _MockUSBError(Exception): + pass + + mock_usb_core = MagicMock() + mock_usb_core.USBError = _MockUSBError + mock_usb = MagicMock() + mock_usb.core = mock_usb_core + sys.modules["usb"] = mock_usb + sys.modules["usb.core"] = mock_usb_core + sys.modules["usb.util"] = MagicMock() + + # Pre-import backend submodules that tests later pull in inside + # `patch.dict("sys.modules", ...)` blocks. If a submodule is loaded + # *during* the patch, patch.dict evicts it on exit and the parent + # package's stale `.` attribute survives, causing later + # `from kvm_serial.backend import x as mod` (attribute path) and + # `from kvm_serial.backend.x import Y` (sys.modules path) to resolve + # to *different* module objects. Pre-loading anchors the modules in + # sys.modules before any patch sees them. + import kvm_serial.backend.keyboard # noqa: F401 + import kvm_serial.backend.implementations.pyusbop # noqa: F401 + + +@pytest.fixture +def _datacomm_manager(): + """ + Provide a fresh DataCommManager singleton wrapping a MagicMock comm to + every test. Ops constructed during the test fetch the mock via + ``DataCommManager.get().comm`` and any writes are observable on it. + The singleton is reset on teardown so tests don't pollute each other. + + Tests that need to assert on specific comm behaviour can grab the mock + with ``DataCommManager.get().comm``; tests that want to install their + own manager can call ``DataCommManager.reset()`` first and re-init. + """ + try: + from kvm_serial.backend.manager import DataCommManager + except ImportError: + yield + return + + DataCommManager.reset() + mgr = DataCommManager(MagicMock(), comm_cls=lambda port: MagicMock()) + try: + yield mgr + finally: + DataCommManager.reset() diff --git a/tests/kvm/test_kvm_settings_persistence.py b/tests/kvm/test_kvm_settings_persistence.py index 33a6769..9749149 100644 --- a/tests/kvm/test_kvm_settings_persistence.py +++ b/tests/kvm/test_kvm_settings_persistence.py @@ -171,6 +171,8 @@ def test_save_settings_success(self): "verbose": "True", "hide_mouse": "True", "keyboard_layout": "en_GB", + "protocol": "ch9329", + "ch9350_state": "2", } with ( @@ -426,12 +428,86 @@ def _setup_mock_menus(self, app): app.baud_rate_menu = MagicMock() app.video_device_menu = MagicMock() app.keyboard_layout_menu = MagicMock() + app.protocol_menu = MagicMock() # Default empty actions app.serial_port_menu.actions.return_value = [] app.baud_rate_menu.actions.return_value = [] app.video_device_menu.actions.return_value = [] app.keyboard_layout_menu.actions.return_value = [] + app.protocol_menu.actions.return_value = [] + + def test_build_comm_cls_default_is_ch9329(self): + """_build_comm_cls returns CH9329Comm when protocol_var is 'ch9329'.""" + from kvm_serial.utils.ch9329 import CH9329Comm + + app = self.create_kvm_app() + app.protocol_var = "ch9329" + assert app._build_comm_cls() is CH9329Comm + + def test_build_comm_cls_ch9350_binds_state(self): + """_build_comm_cls returns a callable that constructs CH9350Comm + with the chosen state.""" + from kvm_serial.utils.ch9350 import CH9350Comm + from tests._utilities import MockSerial + + app = self.create_kvm_app() + app.protocol_var = "ch9350" + app.ch9350_state_var = 3 + + cls = app._build_comm_cls() + comm = cls(MockSerial()) + assert isinstance(comm, CH9350Comm) + assert comm.state == 3 + + def test_load_protocol_default_is_ch9329(self): + """Settings without a protocol entry default to CH9329.""" + app = self.create_kvm_app() + app.baud_rates = self.get_default_baud_rates() + self._setup_mock_menus(app) + + settings = self.create_test_settings({}) + + with ( + patch("kvm_serial.kvm.settings_util.load_settings", return_value=settings), + self.patch_kvm_method(app, "_KVMQtGui__init_serial"), + ): + app._load_settings("test_config.ini") + + self.assertEqual(app.protocol_var, "ch9329") + + def test_load_protocol_ch9350_state(self): + """Saved CH9350 protocol with state restores both fields.""" + app = self.create_kvm_app() + app.baud_rates = self.get_default_baud_rates() + self._setup_mock_menus(app) + + settings = self.create_test_settings({"protocol": "ch9350", "ch9350_state": "3"}) + + with ( + patch("kvm_serial.kvm.settings_util.load_settings", return_value=settings), + self.patch_kvm_method(app, "_KVMQtGui__init_serial"), + ): + app._load_settings("test_config.ini") + + self.assertEqual(app.protocol_var, "ch9350") + self.assertEqual(app.ch9350_state_var, 3) + + def test_load_protocol_invalid_state_falls_back(self): + """An out-of-range ch9350_state falls back to CH9329 with a warning.""" + app = self.create_kvm_app() + app.baud_rates = self.get_default_baud_rates() + self._setup_mock_menus(app) + + settings = self.create_test_settings({"protocol": "ch9350", "ch9350_state": "99"}) + + with ( + patch("kvm_serial.kvm.settings_util.load_settings", return_value=settings), + self.patch_kvm_method(app, "_KVMQtGui__init_serial"), + ): + app._load_settings("test_config.ini") + + self.assertEqual(app.protocol_var, "ch9329") def test_load_keyboard_layout_from_settings(self): """Test loading keyboard layout from saved settings.""" diff --git a/tests/test_control.py b/tests/test_control.py index 3c62efb..b1dc127 100644 --- a/tests/test_control.py +++ b/tests/test_control.py @@ -44,6 +44,49 @@ def test_parse_args_default_values(self): assert not args.verbose assert not args.mouse assert not args.video + # CH9329 is the default; --ch9350 is opt-in. + assert not args.ch9350 + assert args.ch9350_state == 2 + + @patch("sys.argv", ["control.py", "/dev/ttyUSB0"]) + def test_build_comm_cls_defaults_to_ch9329(self): + """Without --ch9350, the comm class defaults to CH9329Comm.""" + from kvm_serial.control import _build_comm_cls, parse_args + from kvm_serial.utils.ch9329 import CH9329Comm + + assert _build_comm_cls(parse_args()) is CH9329Comm + + @patch("sys.argv", ["control.py", "/dev/ttyUSB0", "--ch9329"]) + def test_explicit_ch9329_flag_yields_ch9329(self): + """--ch9329 declares the default explicitly; same class, same behaviour.""" + from kvm_serial.control import _build_comm_cls, parse_args + from kvm_serial.utils.ch9329 import CH9329Comm + + args = parse_args() + assert args.ch9329 + assert not args.ch9350 + assert _build_comm_cls(args) is CH9329Comm + + @patch("sys.argv", ["control.py", "/dev/ttyUSB0", "--ch9329", "--ch9350"]) + def test_protocol_flags_are_mutually_exclusive(self): + """--ch9329 and --ch9350 cannot both be passed.""" + from kvm_serial.control import parse_args + + with pytest.raises(SystemExit): + parse_args() + + @patch("sys.argv", ["control.py", "/dev/ttyUSB0", "--ch9350", "--ch9350-state", "3"]) + def test_build_comm_cls_ch9350(self): + """With --ch9350, the factory yields a CH9350Comm in the requested state.""" + from kvm_serial.control import _build_comm_cls, parse_args + from kvm_serial.utils.ch9350 import CH9350Comm + from tests._utilities import MockSerial + + factory = _build_comm_cls(parse_args()) + assert factory is not None + comm = factory(MockSerial()) + assert isinstance(comm, CH9350Comm) + assert comm.state == 3 def test_stop_threads(self): """Test stop_threads function with mock objects""" diff --git a/tests/utils/test_ch9329.py b/tests/utils/test_ch9329.py new file mode 100644 index 0000000..86ca690 --- /dev/null +++ b/tests/utils/test_ch9329.py @@ -0,0 +1,145 @@ +import pytest +from unittest.mock import patch +from kvm_serial.utils.ch9329 import CH9329Comm + +from tests._utilities import MockSerial, mock_serial + + +class TestCH9329Comm: + """Test suite for CH9329Comm — verifies CH9329 wire-level packet framing.""" + + @patch("serial.Serial", MockSerial) + def test_init(self, mock_serial): + """Test initialization and basic operations of CH9329Comm. + + Tests: + 1. Proper initialization with mock serial port + 2. Sending a single character ('a') scancode + 3. Direct scancode sending + 4. Key release functionality + + Verifies correct packet formation including headers, address, command, + data length, and checksum for each method called. + """ + + mock_serial.port = "/dev/ttyUSB0" + mock_serial.is_open = True + mock_serial.baudrate = 9600 + + dc = CH9329Comm(mock_serial) + + assert dc.port == mock_serial + + # Scancode for letter 'a' + char_to_send = bytes((0x0, 0x0, 0x4, 0x0, 0x0, 0x0, 0x0, 0x0)) + + # Assert the output contains: + # Header: 0x57 0xAB; Address: 0x00; Command 0x02; + # Data length 0x08; Data packet (as above hex: 0000 4000 0000 0000) + # Checksum 0x10 + dc.send(char_to_send) + mock_serial.write.assert_called_once_with( + b"\x57\xab\x00\x02\x08\x00\x00\x04\x00\x00\x00\x00\x00\x10" + ) + mock_serial.write.reset_mock() + + dc.send_scancode(char_to_send) + mock_serial.write.assert_called_once_with( + b"\x57\xab\x00\x02\x08\x00\x00\x04\x00\x00\x00\x00\x00\x10" + ) + mock_serial.write.reset_mock() + + dc.release() + mock_serial.write.assert_called_once_with( + b"\x57\xab\x00\x02\x08\x00\x00\x00\x00\x00\x00\x00\x00\x0c" + ) + mock_serial.write.reset_mock() + + @patch("serial.Serial", MockSerial) + def test_send_scancode_invalid_length(self, mock_serial): + """Test error handling for invalid scancode length. + + Verifies: + 1. Sending a scancode that's too short returns False + 2. No write operation is performed on the serial port + """ + dc = CH9329Comm(mock_serial) + result = dc.send_scancode(bytes([0x0, 0x0])) # Too short + assert result is False + mock_serial.write.assert_not_called() + + @patch("serial.Serial", MockSerial) + @pytest.mark.parametrize("packet_size", [1, 8, 100, 255, 512]) # >255 results in OverflowError + def test_packet_sizes(self, packet_size, mock_serial): + """Test handling of different packet sizes. + + Args: + packet_size: Size of the packet to test (1, 8, 100, or 255 bytes) + mock_serial: Mock serial port fixture + + Verify that CH9329Comm can handle various packet sizes up to 255 bytes. + Larger sizes result in OverflowError. + """ + dc = CH9329Comm(mock_serial) + data = b"x" * packet_size + + if packet_size < 256: + dc.send(data) + mock_serial.write.assert_called_once() + else: + with pytest.raises(OverflowError): + dc.send(data) + + @patch("serial.Serial", MockSerial) + def test_send_mouse_absolute(self, mock_serial): + """Verify the wire format for absolute mouse reports (cmd=0x04). + + Payload is 7 bytes: marker(0x02) + buttons + xL xH + yL yH + wheel. + Source x/y are scaled into the chip's 12-bit absolute space (0..4095); + negative source coordinates (multi-monitor setups) wrap via 4096+dx. + """ + dc = CH9329Comm(mock_serial) + + # All four cases use a 1920x1080 source surface. Scaling: dx maps to + # 0x800 at x=960, wraps to 0x0F2A at x=-100. dy maps to 0x800 at y=540, + # wraps to 0x0E84 at y=-100. + cases = [ + # (x, y, expected wire bytes) + (960, 540, b"\x57\xab\x00\x04\x07\x02\x00\x00\x08\x00\x08\x00\x1f"), + (-100, 540, b"\x57\xab\x00\x04\x07\x02\x00\x2a\x0f\x00\x08\x00\x50"), + (960, -100, b"\x57\xab\x00\x04\x07\x02\x00\x00\x08\x84\x0e\x00\xa9"), + (-100, -100, b"\x57\xab\x00\x04\x07\x02\x00\x2a\x0f\x84\x0e\x00\xda"), + ] + for x, y, expected in cases: + dc.send_mouse_absolute(0, x, y, 1920, 1080) + mock_serial.write.assert_called_once_with(expected) + mock_serial.write.reset_mock() + + @patch("serial.Serial", MockSerial) + def test_send_mouse_relative(self, mock_serial): + """Verify the wire format for relative mouse reports (cmd=0x05). + + Payload is 5 bytes: marker(0x01) + buttons + dx + dy + wheel, + with dx/dy/wheel as 1-byte signed values (-127..+127). + """ + dc = CH9329Comm(mock_serial) + + # Left button down, +5 right, -3 up, wheel +1. + dc.send_mouse_relative(0x01, 5, -3, 1) + mock_serial.write.assert_called_once_with(b"\x57\xab\x00\x05\x05\x01\x01\x05\xfd\x01\x11") + mock_serial.write.reset_mock() + + # Out-of-range deltas clamp to the signed-byte limits (±127), not wrap. + dc.send_mouse_relative(0, 200, -200, 200) + mock_serial.write.assert_called_once_with(b"\x57\xab\x00\x05\x05\x01\x00\x7f\x81\x7f\x8c") + + @patch("serial.Serial", MockSerial) + def test_packet_format_error(self, mock_serial): + """Test ValueError is correctly raised on L40 when called with a bad header""" + dc = CH9329Comm(mock_serial) + + # Error with packet format + char_to_send = bytes((0x0)) + with pytest.raises(ValueError) as exc_info: + dc.send(char_to_send, head=char_to_send) + assert "CH9329 packet header MUST have" in str(exc_info.value) diff --git a/tests/utils/test_ch9350.py b/tests/utils/test_ch9350.py new file mode 100644 index 0000000..8c954d6 --- /dev/null +++ b/tests/utils/test_ch9350.py @@ -0,0 +1,634 @@ +import time +import pytest +from unittest.mock import patch +from kvm_serial.utils.ch9350 import ( + CH9350Comm, + DEFAULT_KBD_PID, + DEFAULT_MOUSE_PID, + HEADER, + HEARTBEAT_FRAME, + _parse_frames, + _split_relative_delta, +) + +from tests._utilities import MockSerial, mock_serial + + +class TestCH9350Comm: + """ + Test suite for CH9350Comm — verifies CH9350L wire-level packet framing + across all four working states. State 1 is reached internally via the + state-0 handshake and is not user-selectable from the constructor. + """ + + @patch("serial.Serial", MockSerial) + def test_init_rejects_unsupported_state(self, mock_serial): + """Constructor refuses states outside the supported range; state 1 + is rejected because it's reached via the state-0 handshake, not + user-selectable.""" + for unsupported in (1, 5, -1): + with pytest.raises(ValueError): + CH9350Comm(mock_serial, state=unsupported) + + @patch("serial.Serial", MockSerial) + def test_send_scancode_wire_format(self, mock_serial): + """ + Keyboard frame is identical across states 2/3/4: HEADER + 0x01 + + the 8-byte HID boot report. Verify on each state, plus length + rejection and release(). + """ + # Scancode for letter 'a' (USB HID code 0x04 in the third byte). + scancode = bytes((0x00, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00)) + expected = b"\x57\xab\x01\x00\x00\x04\x00\x00\x00\x00\x00" + + for state in (2, 3, 4): + dc = CH9350Comm(mock_serial, state=state) + mock_serial.write.reset_mock() + + assert dc.send_scancode(scancode) is True + mock_serial.write.assert_called_once_with(expected) + mock_serial.write.reset_mock() + + # release() emits the all-zeros HID report. + assert dc.release() is True + mock_serial.write.assert_called_once_with( + b"\x57\xab\x01\x00\x00\x00\x00\x00\x00\x00\x00" + ) + mock_serial.write.reset_mock() + + # Short input is rejected without writing. + assert dc.send_scancode(b"\x00\x00") is False + mock_serial.write.assert_not_called() + + @patch("serial.Serial", MockSerial) + def test_state2_relative_wire_format(self, mock_serial): + """ + State 2 emits 7-byte 0x02 frames: + HEADER + 0x02 + btn + dx + dy + wheel + dx/dy/wheel are 1-byte signed; out-of-range values clamp to ±127. + """ + dc = CH9350Comm(mock_serial, state=2) + + dc.send_mouse_relative(0, 5, -3, 1) + mock_serial.write.assert_called_once_with(b"\x57\xab\x02\x00\x05\xfd\x01") + mock_serial.write.reset_mock() + + # Out-of-range values clamp: -200 → -127 (0x81), 200 → 127 (0x7F). + dc.send_mouse_relative(0x01, -200, 200, -200) + mock_serial.write.assert_called_once_with(b"\x57\xab\x02\x01\x81\x7f\x81") + + @patch("serial.Serial", MockSerial) + def test_state2_absolute_translates_to_relative_delta(self, mock_serial): + """ + State 2 has no absolute path on the wire. send_mouse_absolute calls + emit relative deltas computed against the previous absolute call. + First call's prior position defaults to (0, 0). + """ + dc = CH9350Comm(mock_serial, state=2) + + # First absolute call: delta from origin → dx=10, dy=20. + dc.send_mouse_absolute(0, 10, 20, 1920, 1080) + mock_serial.write.assert_called_once_with(b"\x57\xab\x02\x00\x0a\x14\x00") + mock_serial.write.reset_mock() + + # Subsequent call: delta from (10,20) → (15,22) is dx=5, dy=2. + dc.send_mouse_absolute(0, 15, 22, 1920, 1080) + mock_serial.write.assert_called_once_with(b"\x57\xab\x02\x00\x05\x02\x00") + + @patch("serial.Serial", MockSerial) + def test_state3_absolute_wire_format(self, mock_serial): + """ + States 3/4 emit 10-byte 0x04 frames: + HEADER + 0x04 + 0x01 + btn + xL xH + yL yH + wheel + x and y scale to the chip's 10-bit absolute space (0..1023) + carried in 16-bit LE fields. Range empirically calibrated. + """ + dc = CH9350Comm(mock_serial, state=3) + + # Centre of a 1920x1080 surface scales to 0x0200 in both axes + # ((1024 * 960) // 1920 = 512 = 0x0200). + dc.send_mouse_absolute(0, 960, 540, 1920, 1080) + mock_serial.write.assert_called_once_with(b"\x57\xab\x04\x01\x00\x00\x02\x00\x02\x00") + mock_serial.write.reset_mock() + + # Origin → (0, 0). + dc.send_mouse_absolute(0, 0, 0, 1920, 1080) + mock_serial.write.assert_called_once_with(b"\x57\xab\x04\x01\x00\x00\x00\x00\x00\x00") + mock_serial.write.reset_mock() + + # Bottom-right corner → (0x03FF, 0x03FF) (clamped at the 10-bit max). + dc.send_mouse_absolute(0, 1920, 1080, 1920, 1080) + mock_serial.write.assert_called_once_with(b"\x57\xab\x04\x01\x00\xff\x03\xff\x03\x00") + + @patch("serial.Serial", MockSerial) + def test_state3_relative_re_emits_last_position(self, mock_serial): + """ + State 3/4: send_mouse_relative carries no positional info on the + wire. We re-emit the last known absolute position with the new + button/wheel state — that's how clicks and scroll events reach the + target via the absolute-only frame. + """ + dc = CH9350Comm(mock_serial, state=3) + + # No prior absolute call: last position is (0, 0). + dc.send_mouse_relative(0x01, 0, 0, 0) + mock_serial.write.assert_called_once_with(b"\x57\xab\x04\x01\x01\x00\x00\x00\x00\x00") + mock_serial.write.reset_mock() + + # Set the cursor to centre, then a button-only click re-uses that + # position with the new button byte. + dc.send_mouse_absolute(0, 960, 540, 1920, 1080) + mock_serial.write.reset_mock() + + dc.send_mouse_relative(0x01, 0, 0, 0) + mock_serial.write.assert_called_once_with(b"\x57\xab\x04\x01\x01\x00\x02\x00\x02\x00") + mock_serial.write.reset_mock() + + # Scroll event: same position, wheel byte set. + dc.send_mouse_relative(0, 0, 0, 1) + mock_serial.write.assert_called_once_with(b"\x57\xab\x04\x01\x00\x00\x02\x00\x02\x01") + + @patch("serial.Serial", MockSerial) + def test_state4_wire_identical_to_state3(self, mock_serial): + """ + States 3 and 4 are wire-identical from the LC's perspective; the + difference is purely in what the UC advertises on USB to the target + host (HID Mouse vs HID Digitizers, selected by dipswitch). + """ + dc3 = CH9350Comm(mock_serial, state=3) + dc4 = CH9350Comm(mock_serial, state=4) + + dc3.send_mouse_absolute(0, 960, 540, 1920, 1080) + state3_bytes = mock_serial.write.call_args[0][0] + mock_serial.write.reset_mock() + + dc4.send_mouse_absolute(0, 960, 540, 1920, 1080) + state4_bytes = mock_serial.write.call_args[0][0] + + assert state3_bytes == state4_bytes + + # ----------------------------------------------------- state 0/1 builders + + @patch("serial.Serial", MockSerial) + def test_state0_kbd_frame_format(self, mock_serial): + """ + State 0 keyboard frame: + HEADER + 0x88 + LEN + SER + RID + [mod rsvd k0..k5] + CTR + CTRSUM + Per-frame counter starts at 0 and increments; CTRSUM = (CTR + sum(hid)) & 0xFF. + """ + dc = CH9350Comm(mock_serial, state=0) + + # First send: 'a' (0x04) with LSHIFT (0x02). hid sum = 0x01+0x02+0+0x04 = 7. + dc.send_scancode(bytes([0x02, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00])) + mock_serial.write.assert_called_once_with( + b"\x57\xab\x88\x0c\x13\x01\x02\x00\x04\x00\x00\x00\x00\x00\x00\x07" + ) + mock_serial.write.reset_mock() + + # Second send: same scancode, but CTR=1 → CTRSUM=(1+7)&0xFF=0x08. + dc.send_scancode(bytes([0x02, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00])) + mock_serial.write.assert_called_once_with( + b"\x57\xab\x88\x0c\x13\x01\x02\x00\x04\x00\x00\x00\x00\x00\x01\x08" + ) + + @patch("serial.Serial", MockSerial) + def test_state1_kbd_cmd_byte_only_difference(self, mock_serial): + """State 1 keyboard frames differ from state 0 only in the cmd byte + (0x83 vs 0x88) — payload, SER, CTR are unchanged.""" + dc = CH9350Comm(mock_serial, state=0) + dc.state = CH9350Comm.STATE_1 # simulate post-handshake transition + + dc.send_scancode(bytes([0x02, 0x00, 0x04, 0x00, 0x00, 0x00, 0x00, 0x00])) + mock_serial.write.assert_called_once_with( + b"\x57\xab\x83\x0c\x13\x01\x02\x00\x04\x00\x00\x00\x00\x00\x00\x07" + ) + + @patch("serial.Serial", MockSerial) + def test_state0_mouse_frame_format(self, mock_serial): + """ + State 0 relative mouse frame: + HEADER + 0x88 + LEN + SER + RID + [btn dx dy wheel] + CTR + CTRSUM + """ + dc = CH9350Comm(mock_serial, state=0) + dc.send_mouse_relative(0x01, 5, -3, 1) + # data sum: 0x01 + 0x01 + 0x05 + 0xFD + 0x01 = 0x105 & 0xFF = 0x05 + mock_serial.write.assert_called_once_with( + b"\x57\xab\x88\x08\x22\x01\x01\x05\xfd\x01\x00\x05" + ) + + @patch("serial.Serial", MockSerial) + def test_device_connect_frame_format(self, mock_serial): + """0x81 frame: HEADER + 0x81 + PORT + LEN(LE) + DESC + PID + CHK.""" + dc = CH9350Comm(mock_serial, state=0) + # CHK = (sum(desc) + sum(pid)) & 0xFF = (0xab + 0xcd + 0x01 + 0x02) & 0xFF = 0x7b + frame = dc._build_device_connect_frame(b"\xab\xcd", port_id=0x00, device_pid=b"\x01\x02") + assert frame == b"\x57\xab\x81\x00\x02\x00\xab\xcd\x01\x02\x7b" + + @patch("serial.Serial", MockSerial) + def test_device_connect_frame_rejects_bad_pid(self, mock_serial): + dc = CH9350Comm(mock_serial, state=0) + with pytest.raises(ValueError): + dc._build_device_connect_frame(b"\xab", port_id=0x00, device_pid=b"\x01") + + def test_parse_frames_basic(self): + """_parse_frames extracts complete (cmd, payload) tuples, leaves + partials in the buffer, and surfaces pre-header bytes as cmd=-1.""" + # Single complete 0x12 keep-alive (8-byte payload). + buf = bytearray(HEADER + b"\x12" + b"\x40\x00\x03\x15\x00\x07\xac\x20") + frames, remaining = _parse_frames(buf) + assert frames == [(0x12, b"\x40\x00\x03\x15\x00\x07\xac\x20")] + assert remaining == bytearray() + + # Pre-header noise + a 0x86 frame (no payload). + buf = bytearray(b"\xff\x00" + HEADER + b"\x86") + frames, remaining = _parse_frames(buf) + assert frames == [(-1, b"\xff\x00"), (0x86, b"")] + assert remaining == bytearray() + + # 0x88 length-prefixed: payload[0]=LEN means total payload = 1+LEN. + buf = bytearray(HEADER + b"\x88\x04\xaa\xbb\xcc\xdd") + frames, remaining = _parse_frames(buf) + assert frames == [(0x88, b"\x04\xaa\xbb\xcc\xdd")] + assert remaining == bytearray() + + # Partial frame: header + cmd + incomplete payload — leave in buffer. + buf = bytearray(HEADER + b"\x12\x40\x00") + frames, remaining = _parse_frames(buf) + assert frames == [] + assert remaining == bytearray(HEADER + b"\x12\x40\x00") + + @patch("serial.Serial", MockSerial) + def test_handle_frame_pid_ack_transitions_to_state1(self, mock_serial): + """When the UC's 0x12 reflects every announced PID, _handle_frame + moves state 0 → state 1 and sets _uc_seen.""" + dc = CH9350Comm(mock_serial, state=0) + assert dc.state == CH9350Comm.STATE_0 + assert not dc._uc_seen.is_set() + + # Payload with both PIDs matching the defaults → all-acked. + payload = DEFAULT_MOUSE_PID + DEFAULT_KBD_PID + b"\x00\x07\xac\x20" + dc._handle_frame(0x12, payload) + + assert dc._uc_seen.is_set() + assert dc.state == CH9350Comm.STATE_1 + assert dc._uc_p1 == DEFAULT_MOUSE_PID + assert dc._uc_p2 == DEFAULT_KBD_PID + + @patch("serial.Serial", MockSerial) + def test_handle_frame_pid_drop_triggers_reattach(self, mock_serial): + """In state 1, a 0x12 with cleared PIDs (target-side replug) drops + us back to state 0 and signals _reattach_needed.""" + dc = CH9350Comm(mock_serial, state=0) + # Bring us up to state 1 with full ack. + dc._handle_frame(0x12, DEFAULT_MOUSE_PID + DEFAULT_KBD_PID + b"\x00\x07\xac\x20") + assert dc.state == CH9350Comm.STATE_1 + assert not dc._reattach_needed.is_set() + + # UC clears PIDs to 00 00 → STATUS drops → reattach. + dc._handle_frame(0x12, b"\x00\x00\x00\x00\x00\x04\xac\x20") + assert dc.state == CH9350Comm.STATE_0 + assert dc._reattach_needed.is_set() + + @patch("serial.Serial", MockSerial) + def test_handle_frame_ignores_short_or_irrelevant(self, mock_serial): + """Non-0x12 frames and short 0x12 payloads are ignored (no state + change, no exceptions).""" + dc = CH9350Comm(mock_serial, state=0) + + dc._handle_frame(0x82, b"\xa3") # heartbeat — ignored + dc._handle_frame(0x12, b"\x00\x00") # too short for PIDs + assert dc.state == CH9350Comm.STATE_0 + assert not dc._uc_seen.is_set() + + @patch("serial.Serial", MockSerial) + def test_init_rejects_bad_pid_length(self, mock_serial): + """PIDs must be exactly 2 bytes.""" + with pytest.raises(ValueError): + CH9350Comm(mock_serial, state=0, mouse_pid=b"\x40") + with pytest.raises(ValueError): + CH9350Comm(mock_serial, state=0, kbd_pid=b"\x03\x15\x00") + + @patch("serial.Serial", MockSerial) + def test_start_stop_simple_states_spawns_rx_only(self, mock_serial): + """States 2/3/4 spawn just the rx thread (for LED echo); no + tx-maintenance thread is needed since there's no handshake.""" + for state in (2, 3, 4): + dc = CH9350Comm(mock_serial, state=state) + dc.start() + assert dc._rx_thread is not None + assert dc._tx_thread is None + dc.stop() + assert dc._rx_thread is None + + @patch("serial.Serial", MockSerial) + def test_led_echo_simple_states(self, mock_serial): + """ + States 2/3/4 mirror the UC's reported LED state back as 0x80 0x3N + so the source keyboard's lock LEDs follow the target host. Echoed + only on change; the 0xFF pre-enum sentinel is never echoed. + """ + for state in (2, 3, 4): + dc = CH9350Comm(mock_serial, state=state) + mock_serial.write.reset_mock() + + # 0xFF (UC has no target yet) is never echoed. + dc._handle_frame(0x12, b"\x00\x00\x00\x00\xff\x04\xac\x20") + mock_serial.write.assert_not_called() + + # NumLk on (LED bits 0x01) -> echo 0x80 0x31. + dc._handle_frame(0x12, b"\x00\x00\x00\x00\x01\x07\xac\x20") + mock_serial.write.assert_called_once_with(b"\x57\xab\x80\x31") + mock_serial.write.reset_mock() + + # Same LED byte twice -> no re-echo (avoids flooding). + dc._handle_frame(0x12, b"\x00\x00\x00\x00\x01\x07\xac\x20") + mock_serial.write.assert_not_called() + + # CapsLk added (LED bits 0x03) -> echo 0x80 0x33. + dc._handle_frame(0x12, b"\x00\x00\x00\x00\x03\x07\xac\x20") + mock_serial.write.assert_called_once_with(b"\x57\xab\x80\x33") + + @patch("serial.Serial", MockSerial) + def test_led_echo_does_not_run_state0_pid_logic(self, mock_serial): + """Simple-mode 0x12 handling early-returns before the state-0 PID + bookkeeping; state stays at the user-selected mode and _reattach_needed + is never set even when PIDs are 'wrong' (since they're not used here).""" + dc = CH9350Comm(mock_serial, state=2) + dc._handle_frame(0x12, b"\x00\x00\x00\x00\x01\x07\xac\x20") + assert dc.state == CH9350Comm.STATE_2 + assert not dc._reattach_needed.is_set() + + @patch("serial.Serial", MockSerial) + def test_supports_absolute_mouse_property(self, mock_serial): + """States 3/4 forward absolute reports natively; states 0/1/2 translate + absolute targets into relative deltas. The property surfaces this.""" + for state in (0, 2): + assert CH9350Comm(mock_serial, state=state).supports_absolute_mouse is False + for state in (3, 4): + assert CH9350Comm(mock_serial, state=state).supports_absolute_mouse is True + + # State 1 is reached internally from state 0 via the PID-ack handshake; + # an instance promoted to state 1 still maps to relative-only forwarding. + dc = CH9350Comm(mock_serial, state=0) + dc.state = CH9350Comm.STATE_1 + assert dc.supports_absolute_mouse is False + + +class TestSplitRelativeDelta: + """ + State-0/1/2 mouse frames carry signed-byte deltas (±127 per axis). For + absolute targets that translate to a delta exceeding that range, + _split_relative_delta fans the displacement out into a short burst of + chunks each within ±127 that sum to the original delta. + """ + + def test_within_range_passes_through(self): + """A single-frame delta is yielded unchanged.""" + assert list(_split_relative_delta(0, 0)) == [(0, 0)] + assert list(_split_relative_delta(50, -30)) == [(50, -30)] + assert list(_split_relative_delta(127, -127)) == [(127, -127)] + + def test_large_dx_splits(self): + """|dx| > 127 fans out; chunks within ±127 and sum equal to original.""" + chunks = list(_split_relative_delta(300, 0)) + assert len(chunks) == 3 # ceil(300 / 127) = 3 + assert all(abs(cdx) <= 127 and abs(cdy) <= 127 for cdx, cdy in chunks) + assert sum(c[0] for c in chunks) == 300 + assert sum(c[1] for c in chunks) == 0 + + def test_large_dy_splits(self): + chunks = list(_split_relative_delta(0, -400)) + assert len(chunks) == 4 # ceil(400 / 127) = 4 + assert all(abs(cdx) <= 127 and abs(cdy) <= 127 for cdx, cdy in chunks) + assert sum(c[1] for c in chunks) == -400 + + def test_diagonal_split_preserves_ratio(self): + """Both axes split together; chunk-count driven by the larger axis.""" + chunks = list(_split_relative_delta(300, 50)) + assert len(chunks) == 3 + assert all(abs(cdx) <= 127 and abs(cdy) <= 127 for cdx, cdy in chunks) + assert sum(c[0] for c in chunks) == 300 + assert sum(c[1] for c in chunks) == 50 + + def test_negative_axes(self): + chunks = list(_split_relative_delta(-500, -250)) + assert all(abs(cdx) <= 127 and abs(cdy) <= 127 for cdx, cdy in chunks) + assert sum(c[0] for c in chunks) == -500 + assert sum(c[1] for c in chunks) == -250 + + def test_state0_absolute_fans_out_on_wire(self, mock_serial): + """Verify the integration: a state-0 send_mouse_absolute call with a + delta > 127 produces multiple consecutive 0x88 mouse frames whose + chunk dx/dy sum to the requested displacement.""" + from unittest.mock import patch + + with patch("serial.Serial", MockSerial): + dc = CH9350Comm(mock_serial, state=0) + # First call establishes baseline at (0, 0); second call requests + # a 300-pixel jump in x. Expect three 0x88 frames, each within ±127 + # in dx, summing to 300. + dc.send_mouse_absolute(0, 0, 0, 1920, 1080) + mock_serial.write.reset_mock() + dc.send_mouse_absolute(0, 300, 0, 1920, 1080) + assert mock_serial.write.call_count == 3 + total_dx = 0 + for call in mock_serial.write.call_args_list: + frame = call.args[0] + # 0x88 frame: header + cmd + LEN + SER + RID + btn + dx + dy + wheel + ctr + sum + assert frame[2] == 0x88 # cmd + dx_byte = frame[7] + dx_signed = dx_byte - 256 if dx_byte > 127 else dx_byte + assert -127 <= dx_signed <= 127 + total_dx += dx_signed + assert total_dx == 300 + + def test_state2_absolute_fans_out_on_wire(self, mock_serial): + """State 2 also fans out; emits multiple 0x02 simple frames.""" + from unittest.mock import patch + + with patch("serial.Serial", MockSerial): + dc = CH9350Comm(mock_serial, state=2) + dc.send_mouse_absolute(0, 0, 0, 1920, 1080) + mock_serial.write.reset_mock() + dc.send_mouse_absolute(0, 0, 250, 1920, 1080) + assert mock_serial.write.call_count == 2 # ceil(250 / 127) = 2 + total_dy = 0 + for call in mock_serial.write.call_args_list: + frame = call.args[0] + assert frame[2] == 0x02 # state-2 cmd byte + dy_byte = frame[5] + dy_signed = dy_byte - 256 if dy_byte > 127 else dy_byte + assert -127 <= dy_signed <= 127 + total_dy += dy_signed + assert total_dy == 250 + + def test_wheel_only_on_first_chunk(self, mock_serial): + """When fan-out applies, the wheel value rides only the first frame + so a single scroll request isn't multiplied by the fan-out count.""" + from unittest.mock import patch + + with patch("serial.Serial", MockSerial): + dc = CH9350Comm(mock_serial, state=2) + dc.send_mouse_absolute(0, 0, 0, 1920, 1080) + mock_serial.write.reset_mock() + dc.send_mouse_absolute(0, 300, 0, 1920, 1080, wheel=3) + assert mock_serial.write.call_count == 3 + # State-2 frame: header(2) + cmd(1) + btn(1) + dx(1) + dy(1) + wheel(1) = 7B + wheels = [call.args[0][6] for call in mock_serial.write.call_args_list] + assert wheels == [3, 0, 0] + + +class TestCH9350ParseFramesEdgeCases: + """Edge-case coverage for _parse_frames: partial buffers and uncommon cmds.""" + + def test_partial_header_only_returns_empty(self): + """Only the 2-byte header in buffer: need cmd byte too, so hold.""" + frames, remaining = _parse_frames(bytearray(b"\x57\xab")) + assert frames == [] + assert remaining == bytearray(b"\x57\xab") + + def test_partial_0x83_missing_len_byte(self): + """0x83 cmd present but LEN byte not yet received.""" + frames, remaining = _parse_frames(bytearray(b"\x57\xab\x83")) + assert frames == [] + + def test_partial_0x83_truncated_payload(self): + """LEN says 5 bytes but only 2 payload bytes present.""" + buf = bytearray(b"\x57\xab\x83\x05\x13\x01") # need 4+5=9, have 6 + frames, remaining = _parse_frames(buf) + assert frames == [] + + def test_complete_0x81_device_connection_frame(self): + """A well-formed 0x81 Device Connection Frame parses to (0x81, payload).""" + desc = b"\x01\x02\x03" + pid = b"\x40\x00" + chk = (sum(desc) + sum(pid)) & 0xFF + plen = len(desc) + raw = ( + b"\x57\xab\x81\x00" + + bytes([plen & 0xFF, (plen >> 8) & 0xFF]) + + desc + + pid + + bytes([chk]) + ) + frames, remaining = _parse_frames(bytearray(raw)) + assert len(frames) == 1 + assert frames[0][0] == 0x81 + assert remaining == bytearray() + + def test_partial_0x81_missing_len_field(self): + """0x81 present but fewer than 6 bytes total: hold until LEN is readable.""" + frames, remaining = _parse_frames(bytearray(b"\x57\xab\x81\x00")) + assert frames == [] + + def test_partial_0x81_truncated_payload(self): + """LEN field present but payload bytes incomplete.""" + plen = 10 + buf = bytearray(b"\x57\xab\x81\x00" + bytes([plen, 0x00]) + b"\x01\x02") + frames, remaining = _parse_frames(buf) + assert frames == [] + + def test_unknown_cmd_bounded_by_next_header(self): + """Unknown cmd bytes are collected up to the start of the next header.""" + heartbeat = b"\x57\xab\x82\xa3" + buf = bytearray(b"\x57\xab\xff\x01\x02") + bytearray(heartbeat) + frames, _ = _parse_frames(buf) + cmds = [f[0] for f in frames] + assert 0xFF in cmds + assert 0x82 in cmds + + def test_unknown_cmd_large_buffer_no_header_flushed(self): + """Unknown cmd, no next header, buf > 128 bytes: frame emitted, buffer cleared.""" + buf = bytearray(b"\x57\xab\xff") + bytearray(200) + frames, remaining = _parse_frames(buf) + assert any(f[0] == 0xFF for f in frames) + assert remaining == bytearray() + + def test_unknown_cmd_small_buffer_no_header_held(self): + """Unknown cmd, no next header, buf <= 128 bytes: hold and wait for more data.""" + buf = bytearray(b"\x57\xab\xff\x01\x02") + frames, remaining = _parse_frames(buf) + assert frames == [] + assert remaining == buf + + +class TestCH9350CommInternals: + """Direct tests of CH9350Comm lifecycle hooks and internal frame helpers.""" + + def test_start_called_twice_raises(self, mock_serial): + dc = CH9350Comm(mock_serial, state=2) + with patch.object(dc, "_rx_loop"): + dc.start() + with pytest.raises(RuntimeError, match="start\\(\\) called twice"): + dc.start() + dc.stop() + + def test_start_state0_spawns_tx_thread(self, mock_serial): + """State 0 creates both rx and tx-maintenance threads.""" + dc = CH9350Comm(mock_serial, state=0) + with patch.object(dc, "_rx_loop"), patch.object(dc, "_tx_maint_loop"): + dc.start() + assert dc._tx_thread is not None + assert dc._rx_thread is not None + dc.stop() + assert dc._tx_thread is None + assert dc._rx_thread is None + + def test_start_state2_no_tx_thread(self, mock_serial): + """States 2/3/4 spawn the rx thread but no tx-maintenance thread.""" + dc = CH9350Comm(mock_serial, state=2) + with patch.object(dc, "_rx_loop"): + dc.start() + assert dc._tx_thread is None + dc.stop() + + def test_heartbeat_sends_frame_and_updates_timestamp(self, mock_serial): + dc = CH9350Comm(mock_serial, state=2) + before = time.time() + dc._heartbeat() + mock_serial.write.assert_called_once_with(HEARTBEAT_FRAME) + assert dc._last_hb >= before + + def test_send_announce_sends_0x89_frame(self, mock_serial): + dc = CH9350Comm(mock_serial, state=2) + dc._send_announce() + mock_serial.write.assert_called_once_with(b"\x57\xab\x89") + + def test_announce_descriptors_sends_two_0x81_frames(self, mock_serial): + dc = CH9350Comm(mock_serial, state=0) + with patch("kvm_serial.utils.ch9350.time.sleep"): + dc._announce_descriptors() + assert mock_serial.write.call_count == 2 + for c in mock_serial.write.call_args_list: + frame = c.args[0] + assert frame[:2] == b"\x57\xab" + assert frame[2] == 0x81 + + def test_maybe_retransmit_sends_when_unacked_and_interval_elapsed(self, mock_serial): + dc = CH9350Comm(mock_serial, state=0) + dc._last_mouse_announce = 0.0 + dc._last_kbd_announce = 0.0 + # _uc_p1/p2 default to b"\x00\x00", which differs from both PIDs + dc._maybe_retransmit_descriptors(time.time()) + assert mock_serial.write.call_count == 2 + + def test_maybe_retransmit_skips_when_recently_announced(self, mock_serial): + dc = CH9350Comm(mock_serial, state=0) + now = time.time() + dc._last_mouse_announce = now + dc._last_kbd_announce = now + dc._maybe_retransmit_descriptors(now) + mock_serial.write.assert_not_called() + + def test_run_attach_sequence_no_wait_sends_full_sequence(self, mock_serial): + """wait_for_uc=False skips the UC-seen guard and sends the full attach sequence.""" + dc = CH9350Comm(mock_serial, state=0) + with patch("kvm_serial.utils.ch9350.time.sleep"): + dc._run_attach_sequence(wait_for_uc=False) + cmds = [c.args[0][2] for c in mock_serial.write.call_args_list] + # 0x86 + 0x80 x2 + 0x82 (heartbeat) + 0x89 + 0x81 x2 (descriptors) = 7 writes + assert mock_serial.write.call_count == 7 + assert 0x86 in cmds + assert 0x80 in cmds + assert 0x89 in cmds + assert 0x81 in cmds diff --git a/tests/utils/test_communication.py b/tests/utils/test_communication.py index 5d3a4a5..84b6141 100644 --- a/tests/utils/test_communication.py +++ b/tests/utils/test_communication.py @@ -1,95 +1,18 @@ import pytest import termios from unittest.mock import patch -from kvm_serial.utils.communication import DataComm, list_serial_ports +from kvm_serial.utils.communication import list_serial_ports from tests._utilities import MockSerial, mock_serial -class TestDataComm: - """Test Suite for DataComm class""" +class TestListSerialPorts: + """Test suite for the cross-platform list_serial_ports() helper. - @patch("serial.Serial", MockSerial) - def test_init(self, mock_serial): - """Test initialization and basic operations of DataComm. - - Tests: - 1. Proper initialization with mock serial port - 2. Sending a single character ('a') scancode - 3. Direct scancode sending - 4. Key release functionality - - Verifies correct packet formation including headers, address, command, - data length, and checksum for each method called. - """ - - mock_serial.port = "/dev/ttyUSB0" - mock_serial.is_open = True - mock_serial.baudrate = 9600 - - dc = DataComm(mock_serial) - - assert dc.port == mock_serial - - # Scancode for letter 'a' - char_to_send = bytes((0x0, 0x0, 0x4, 0x0, 0x0, 0x0, 0x0, 0x0)) - - # Assert the output contains: - # Header: 0x57 0xAB; Address: 0x00; Command 0x02; - # Data length 0x08; Data packet (as above hex: 0000 4000 0000 0000) - # Checksum 0x10 - dc.send(char_to_send) - mock_serial.write.assert_called_once_with( - b"\x57\xab\x00\x02\x08\x00\x00\x04\x00\x00\x00\x00\x00\x10" - ) - mock_serial.write.reset_mock() - - dc.send_scancode(char_to_send) - mock_serial.write.assert_called_once_with( - b"\x57\xab\x00\x02\x08\x00\x00\x04\x00\x00\x00\x00\x00\x10" - ) - mock_serial.write.reset_mock() - - dc.release() - mock_serial.write.assert_called_once_with( - b"\x57\xab\x00\x02\x08\x00\x00\x00\x00\x00\x00\x00\x00\x0c" - ) - mock_serial.write.reset_mock() - - @patch("serial.Serial", MockSerial) - def test_send_scancode_invalid_length(self, mock_serial): - """Test error handling for invalid scancode length. - - Verifies: - 1. Sending a scancode that's too short returns False - 2. No write operation is performed on the serial port - """ - dc = DataComm(mock_serial) - result = dc.send_scancode(bytes([0x0, 0x0])) # Too short - assert result is False - mock_serial.write.assert_not_called() - - @patch("serial.Serial", MockSerial) - @pytest.mark.parametrize("packet_size", [1, 8, 100, 255, 512]) # >255 results in OverflowError - def test_packet_sizes(self, packet_size, mock_serial): - """Test handling of different packet sizes. - - Args: - packet_size: Size of the packet to test (1, 8, 100, or 255 bytes) - mock_serial: Mock serial port fixture - - Verify that DataComm can handle various packet sizes up to 255 bytes. - Larger sizes result in OverflowError. - """ - dc = DataComm(mock_serial) - data = b"x" * packet_size - - if packet_size < 256: - dc.send(data) - mock_serial.write.assert_called_once() - else: - with pytest.raises(OverflowError): - dc.send(data) + All tests in this suite are currently skipped — they predate the move to + pyserial's list_ports API and need rewriting to mock that surface rather + than the prior glob-based enumeration. Kept in place as a TODO marker. + """ # TODO: fix to correctly use mock. @pytest.mark.skip("Broken: serial.Serial imported; fix to use mock.") @@ -158,17 +81,6 @@ def mock_serial_init(self, port=None): assert "COM2" not in ports assert len(ports) == 2 - @patch("serial.Serial", MockSerial) - def test_packet_format_error(self, mock_serial): - """Test ValueError is correctly raised on L40 when called with a bad header""" - dc = DataComm(mock_serial) - - # Error with packet format - char_to_send = bytes((0x0)) - with pytest.raises(ValueError) as exc_info: - dc.send(char_to_send, head=char_to_send) - assert "DataComm packet header MUST have" in str(exc_info.value) - # TODO: fix to correctly use mock. @pytest.mark.skip("Broken: serial.Serial imported; fix to use mock.") @patch("kvm_serial.utils.communication.serial.Serial")