USB HID relay gateware for a Cynthion r1.4 (Lattice ECP5 LFE5U-12F, CABGA256), written in Amaranth on top of LUNA.
It acts as a USB host on the TARGET-A port — powering the port, resetting, enumerating an attached HID device (a mouse, keyboard or HID gamepad) and polling its interrupt-IN endpoints — and presents a clone of that device to a PC on the AUX port, relaying its reports and optionally mutating them in flight. Both links negotiate High Speed (480 Mb/s) through a host-side chirp handshake. Debug registers are read over JTAG with Apollo.
mouse / receiver ──> TARGET-A [ Cynthion r1.4 ] AUX ──> PC
CONTROL ──> build machine (apollo, regdebug)
PMOD-A ──> injection-control MCU (optional)
enumerator.py owns the TARGET side: it drives TARGET-A power itself
(aux_vbus_en) rather than sensing VBUS, resets the bus, runs the host half of
the USB 2.0 §7.1.7.5 chirp handshake, and walks the standard descriptor
sequence. Enumeration is deliberately bounded — at most 4 interfaces and 4
interrupt-IN endpoints (any numbers 1..15, no two alike), max packet size 64,
alternate setting 0 only — and it commits a capture for any HID device (bInterfaceClass == 3, whatever its protocol) with at least one interrupt-IN endpoint, failing
with UNSUPPORTED_TOPOLOGY otherwise. A failed attempt re-resets and retries, up
to six times.
Captured descriptors land in a DescriptorStore, copied verbatim into the AUX
clone's private store, so LUNA's USBDevice serves the PC the same VID/PID,
configuration, HID and report descriptors the real device gave us. One
InterruptInPoller runs per captured endpoint; they share a single
USBHostTransactionEngine round-robin through an arbiter, with SOF and
enumeration control transfers holding strict priority. bInterval is stored in
the device's own encoding and decoded against the negotiated speed — frames at
Full Speed, 2**(bInterval-1) microframes at High Speed — because the clone
hands the PC the original byte. AUX mirrors whatever TARGET negotiated
(device.full_speed_only is driven from ~host.high_speed), so the clone is
transparent in speed as well as in descriptors.
Reports from all pollers merge into one interface- and endpoint-tagged byte
stream, pass through ReportInjectionEngine, and are relayed to the matching AUX
endpoint. The injection engine mutates complete reports against a field map
uploaded at runtime by an external MCU over the SPI control link (see
Report injection). With no MCU attached (link_ready = 0)
no map is ever active and every report passes through unmodified.
The PC's traffic reaches the real device too. HID class control requests on EP0
— GET/SET_REPORT, SET_IDLE and SET_PROTOCOL included — are forwarded verbatim
through control_relay.py, and one HID interrupt-OUT endpoint (rumble, lightbar,
keyboard LEDs) is relayed by out_writer.py. A device the PC puts in boot
protocol (a BIOS) sends boot-layout reports the field map does not describe, so
boot_protocol.py tracks it per endpoint, injection stands aside there, and a PC
bus reset or SET_CONFIGURATION on the clone replays SET_PROTOCOL(report) to the
real device, which never sees either event itself. Either event also starts the
clone's own endpoints over: every data toggle restarts at DATA0, and reports
queued before it are discarded rather than served late. The host polls the real
device from enumeration on, but the PC polls nothing until it configures the
clone.
Vendor-type control requests are not forwarded: the clone STALLs them, by
design. Forwarding them would let the PC send arbitrary vendor writes —
firmware-update commands included — to the real device; class requests already
cross that boundary, and HID configuration tools mostly use class feature
reports, which do work. A forwarded class response is limited to the relay's
64-byte buffer; a longer one is STALLed. The clone captures no string 0xEE or
BOS descriptor, so Windows never asks for MS OS descriptors.
-
A Cynthion r1.4 and a USB HID device: a mouse (or its wireless receiver), a keyboard, or a HID gamepad such as a DS4.
-
Python 3.11 or newer. Runtime and dev dependencies are pinned in
pyproject.tomland installed by the command below. -
An ECP5 toolchain —
yosys,nextpnr-ecp5,ecppack— from a single, recent OSS CAD Suite release. Use yosys 0.60 or newer; older releases do not close timing on this design reliably. The tested release is oss-cad-suite 2026-09-01 (yosys 0.68). Check what is on yourPATHbefore trusting a build:yosys -V && which yosys nextpnr-ecp5 ecppack
The apollo CLI is installed as a dependency of cynthion by the step below.
python3 -m venv .venv
. .venv/bin/activate
python3 -m pip install -e '.[dev]'LUNA_PLATFORM=cynthion.gateware.platform:CynthionPlatformRev1D4 make buildThe Makefile refuses to run without LUNA_PLATFORM — LUNA resolves the board
from that variable and there is no default. The bitstream is written to
build/hurra-cynthion.bit. make rtlil writes the platform-independent host
core to build/host.il and needs no platform or FPGA toolchain.
nextpnr writes no bitstream when timing fails, so the existence of
build/hurra-cynthion.bit is the pass signal — not a frequency parsed from the
log. The 60 MHz ULPI domain closes with little margin and placement is
seed-sensitive, so any RTL change should be validated with a seed sweep rather
than a single build. build_env.py pins the placer seed and the solver thread
counts so that builds are reproducible; a build whose composed nextpnr options
lose the timing-weight flag aborts rather than emitting a marginal result.
apollo configure build/hurra-cynthion.bitThat is a volatile SRAM load: a reset or power cycle reverts the FPGA to whatever is in configuration flash. To make it persistent:
apollo flash-program build/hurra-cynthion.bit
apollo reconfigureA flash-resident board comes up and relays with no debug connection attached.
Report mutation is driven by an optional external MCU over a 32-byte fixed-slot
SPI link on PMOD-A, one slot every 125 µs. The wire format is versioned in
protocol/report_injection_wire.json, the single source of truth from which the
Python and C bindings are generated. The MCU uploads a descriptor-derived field
map and then issues motion, button and mask commands; the FPGA applies them to
live reports transactionally.
Firmware is provided for two controllers, both speaking the same contract:
firmware/mcxn947/— NXP FRDM-MCXN947, the current controller.firmware/ch32h417/— WCH CH32H417, the earlier controller.
With no MCU attached the relay is fully transparent.
The production top exposes report_injection_register_map() over LUNA's
JTAG-tunnelled debug SPI. regdebug is the schema-aware reader:
PYTHONPATH=src python3 -m hurra_cynthion.regdebug --no-force-offline \
--map report-injection dump
PYTHONPATH=src python3 -m hurra_cynthion.regdebug --no-force-offline \
--map report-injection read usb_speed
PYTHONPATH=src python3 -m hurra_cynthion.regdebug --no-force-offline \
--map report-injection watch native_reports polls_issuedSubcommands are magic, read, write, dump, mem and watch.
--no-force-offline matters: the default forces the FPGA offline before
connecting, which stops the relay you are trying to observe.
Registers are 32 bits with fields packed LSB-first, allocated in declaration
order and appended only — inserting one shifts every later address and silently
invalidates readers built against the old map. Counters mostly saturate;
spi_slots, polls_issued and poll_naks wrap mod 2**32 and are meant to be
read as deltas over a window. That trio diagnoses a low report rate:
native_reports counts reports received, so a poll that was never issued and a
poll the device NAKed produce an identical number, while polls_issued against
the free-running spi_slots reference separates a slow host from a quiet device.
speed_policy is the one writable register. Bit 0 forces AUX to Full Speed,
bit 1 suppresses the TARGET chirp; AUX follows TARGET, so bit 1 alone returns the
whole relay to Full Speed without building a second bitstream.
PYTHONPATH=src python3 -m hurra_cynthion.regdebug --no-force-offline \
--map report-injection write speed_policy 2| LED | Signal |
|---|---|
| 0 | TARGET device attached |
| 1 | enumeration in progress |
| 2 | enumerated |
| 3 | report activity (toggles per report) |
| 4 | host error |
| 5 | AUX clone configured by the PC |
make lint # ruff check + ruff format --check over src and tests
make test # pytest; Amaranth simulation only, no hardware needed
make rtlil # platform-independent host core; no FPGA toolchainmake verify runs those plus the CH32H417 firmware build and its host-side unit
tests, which need riscv-none-elf-gcc on PATH. The MCXN947 firmware has its
own make test and make check under firmware/mcxn947/.
| Path | |
|---|---|
src/hurra_cynthion/ |
Amaranth gateware and the host-side Python tools |
tests/ |
pytest simulation suite |
protocol/ |
report_injection_wire.json, the versioned wire contract for the MCU link |
tools/ |
generate_report_injection_wire.py, which emits the Python and C sides of that contract |
firmware/mcxn947/ |
MCXN947 controller firmware for the injection link |
firmware/ch32h417/ |
CH32H417 controller firmware for the injection link |
firmware/teensy_hs_mouse/ |
synthetic High Speed HID mouse used as a bench test instrument |