SimGateway
The SimGateway is the one device the PC plugs into. It's an off-the-shelf RP2040 module that presents a single composite USB device to DCS and relays everything to the rest of the cockpit over a UART. This page covers how it works. For why a separate gateway exists at all — the STM32 USB failure that forced it — see Design Decisions D1.
Hardware
- MCU: an off-the-shelf RP2040 module (e.g. Raspberry Pi Pico). No custom PCB.
- USB: the single connection to the PC.
- UART: RP2040
Serial1/ UART0 — GP0 (TX → PanelBridge RX/PA3) and GP1 (RX ← PanelBridge TX/PA2), at 250000 baud. Both sides are 3.3 V; no level shifter.
SimGateway::setup(Serial1) configures the pins, starts the UART at 250000, sets the USB
identity, and enumerates the TinyUSB HID device. The baud rate matches the DCS-BIOS protocol
rate exactly, so there's no buffering mismatch on the relay.
USB composite device
SimGateway enumerates as a composite CDC + HID device on one USB port:
- CDC (serial) carries the DCS-BIOS stream, both directions.
- HID (joystick) presents flight-control axes and buttons to the PC.
USB identity is set at runtime before the HID device starts (TinyUSB replaces the default Pico USB stack):
TinyUSBDevice.setID(0x2E8A, 0x4134); // VID = Raspberry Pi, PID = A-4E Skyhawk
TinyUSBDevice.setManufacturerDescriptor("OpenSkyhawk");
TinyUSBDevice.setProductDescriptor("A-4E Skyhawk");
Serial.setStringDescriptor("A-4E Skyhawk DCS-BIOS"); // CDC interface name (iInterface)
The product string names the HID joystick, but the CDC serial interface advertises its own
name (iInterface) — A-4E Skyhawk DCS-BIOS — so host tooling such as OpenSkyhawk Client can
match the port by name, not just VID/PID + CDC class.
HID device profile
The HID descriptor is built on the Adafruit TinyUSB library and frozen:
| Capability | Value |
|---|---|
| Axes | 8 — X, Y, Z, Rx, Ry, Rz, Slider, Dial (16-bit signed, ±32767) |
| Buttons | 128 (1 bit each) |
| Hat switches | 4 (4-bit nibble each; 0xF = centered) |
| Report size | 34 bytes |
This covers all 7 planned OpenSkyhawk axes (Roll, Pitch, Throttle, Rudder, Brake L, Brake R,
Zoom) with one spare, plus headroom on buttons and hats. Validated against DCS World
(DirectInput) as a full DIJOYSTATE2 match — 8 axes, 128 buttons, 4 POV hats, no limits
exceeded.
Never grow the HID report past 64 bytes
TinyUSB on the RP2040 hardcodes CFG_TUD_HID_EP_BUFSIZE = 64; build-flag overrides are
silently ignored. A report over 64 bytes makes sendReport() return false and drop
silently — no error, no crash. The production report is 34 bytes; keep it well under 64.
The relay contract
This is the heart of SimGateway, and it's deliberately dumb:
- Every byte from USB CDC is forwarded to the UART immediately.
- Every UART byte that is not part of a HID frame is forwarded to USB CDC immediately.
- SimGateway never buffers, parses, or delays DCS-BIOS bytes, and never runs the DCS-BIOS library. PanelBridge therefore sees the stream with USB-CDC latency only.
The two streams share one UART because they can't collide: DCS-BIOS text is pure printable
ASCII + LF (every byte ≤ 0x7F), while HID frames start with the magic bytes 0xAA 0x55
(both have bit 7 set).
The USB side carries a third stream — the axis calibration protocol, which the OpenSkyhawk Client uses to read and write the endpoints stored on the gateway. It is not separated by alphabet, because the client stops relaying DCS while it talks and because a frame can be mistaken for corrupted traffic in the other direction. Instead every calibration frame carries a checksum and a length fixed by its message type, and anything failing either check is passed straight through to the cockpit untouched. A calibration session therefore cannot swallow DCS-BIOS data, and health reporting keeps flowing while it is open.
This "dumb relay" is also why node-status reporting (#86)
needs no SimGateway change: PanelBridge reports connected PanelGroup nodes to the host over
DCS-BIOS itself — _NODE_STATUS ASCII command messages downstream, and a roster request as a
DCS-BIOS export write to reserved address 0x86FE upstream. Both ride the existing streams and
transit SimGateway transparently; PanelBridge owns the feature.
controlId routing logic
Incoming HID frames (PanelBridge → SimGateway) are fixed 6-byte little-endian:
| Bytes | Field |
|---|---|
| 0–1 | Magic 0xAA 0x55 |
| 2–3 | controlId (uint16 LE) |
| 4–5 | value (uint16 LE) |
SimGateway demultiplexes the UART byte stream:
- Byte ≤
0x7F→ DCS-BIOS byte → forward to USB CDC. - Byte
0xAAfollowed by0x55→ HID frame: read 4 more bytes, dispatch bycontrolId. - Byte
0xAAnot followed by0x55→ not a frame: forward both bytes and resume scanning (self-healing resync at the next byte boundary).
HID controls are owned entirely here — PanelBridge wraps them but never interprets them. The
controlId ranges SimGateway dispatches:
| Range | Type | Handler |
|---|---|---|
0x0010–0x001F |
Axes | HIDAxis |
0x0020–0x002F |
Hat switches | HIDHatSwitch |
0x0030–0x00AF |
Buttons | HIDButton |
HIDAxis maps the incoming 0–65535 value to ±32767 internally; HIDButton treats
value != 0 as pressed; HIDHatSwitch reads a direction nibble (0 = centered, 1–8 = N…NW).
Dispatch and startup behaviour
Each SimGateway::loop() iteration drains all pending HID frames, applies them to the axis /
button / hat objects, and then sends the HID report once.
Sending once per drain cycle — not once per frame — keeps the HID report rate predictable and
avoids redundant USB packets.
At startup, SimGateway::setup() owns all of it: USB identity, GP0/GP1 pin configuration,
Serial1.begin(250000), and TinyUSB HID enumeration. The sketch only declares its HIDAxis
/ HIDButton / HIDHatSwitch objects and calls setup() / loop() — DCS-BIOS relay is
automatic and needs no declarations.
Status LEDs
The gateway carries two board-mounted status LEDs on the Gateway_Bridge board — GREEN on
GP2, RED on GP3 — so you can read its USB / data / fault state at a glance without a host. They
are active-high (HIGH = on) and driven by a non-blocking millis() animator ticked from
SimGateway::loop(); the RP2040 module's onboard WS2812 is not used. State priority runs highest
to lowest — the topmost active row wins:
| Priority | State | What it means | LED |
|---|---|---|---|
| 1 (highest) | FAULT |
A UART hardware error on the PanelBridge link (overrun / framing / parity) | Red, fast blink (4 Hz) |
| 2 | NO_HOST |
USB not enumerated — no PC, or the cable was unplugged | Red, solid |
| 3 | STREAMING |
DCS-BIOS data flowing from the PC (seen within the last ~500 ms) | Green, solid |
| 4 | USB_IDLE |
USB connected, but no recent DCS-BIOS data | Green, slow blink (1 Hz) |
| 5 (lowest) | INIT |
Just booted, USB not yet mounted (brief) | Red, slow blink |
When the fault light clears
FAULT is latched, not momentary: it stays on for at least ~2 s (so a single glitch is
visible) and then clears only once clean PanelBridge data resumes on the UART — an
error-free byte received after the fault. A fault on an otherwise-silent link therefore
stays lit until traffic returns error-free, rather than timing out on its own. The error is
read straight from the RP2040's uart0 PL011 error register (RSR), because the
arduino-pico SerialUART driver doesn't surface overrun/framing flags any other way.
Active-high, separate power domain
Both LEDs are active-high (HIGH = lit) and sit on the RP2040 module's own 3V3 LDO, isolated
from the STM32 rail — so they reflect the gateway's state even if the STM32 side is
unpowered or held in reset. The two BRIDGE LEDs next to them (PB14/PB15) are driven
separately by the STM32 firmware.