The firmware
Configure everything in a JSON file, flash it once, and the box behaves exactly how you set it up. No code changes, no drivers.
The firmware is CircuitPython for an RP2040 with a TCA9555 I/O expander. The device presents as a standard USB joystick with 128 buttons and 8 axes. A single JSON config file drives all the behaviour, so the same firmware fits any layout you build.
The rule engine
Rules run every cycle, 200 times a second, in the order you list them. Any D-pin not claimed by a rule automatically passes through, so D1 lands on button 1 and you only write rules for the things that need to be cleverer than that. A-pins only do what a rule tells them to, so a switch wired to A1 stays silent until a rule names it.
There are nine rule types:
- MAP sends an input straight to an output, with an optional invert.
- NOR turns on only when all its inputs are off, which is how a 3-way switch reports its middle position.
- TOGGLE flips its output on each rising edge of the input.
- PULSE fires its output for a set time, after an optional delay.
- ENCODER reads a quadrature pair and produces clockwise and counter-clockwise outputs.
- AXIS_INC and AXIS_DEC move an axis by a step on each rising edge.
- ANALOG reads a sensor on an A-pin and drives an axis with it.
- THRESHOLD turns an analog signal into a button.
Rules can read the outputs of earlier rules in the same cycle, which is what makes the useful combinations possible. An encoder feeding an axis is three rules stacked:
{ "type": "ENCODER", "inputs": ["D17", "D18"], "cw": "B17", "ccw": "B18" },
{ "type": "AXIS_INC", "input": "B17", "axis": "AX1", "step": 2048 },
{ "type": "AXIS_DEC", "input": "B18", "axis": "AX1", "step": 2048 }A 3-way switch, where D3 is up, D4 is down, and the NOR covers the middle:
{ "type": "NOR", "inputs": ["D3", "D4"], "output": "B30" }Analog inputs
Name an A-pin in an ANALOG or THRESHOLD rule and it stops being a digital input: no pull-up, 0 to 3.3 V in, sampled every cycle. The A-pins sit on the RP2040's ADC (GP26 to GP29). Never feed one more than 3.3 V; a 5 V sensor needs a divider.
ANALOG reads one A-pin and writes one axis through a fixed pipeline: sample, filter, range, invert, curve, hysteresis. The range is a min and a max, or a min, center, and max with a deadzone for sensors that rest in the middle. All values are on the ADC's 16 bit scale. A pot across 3.3 V and GND needs nothing but the pin and the axis. A hall effect pedal only swings over part of the range, so it gets its resting and fully pressed readings as min and max, which is exactly what the desktop app's Calibrate button measures for you.
THRESHOLD makes a button out of an analog signal. Its input is an A-pin or an axis id, with exactly one of above or below, and a hysteresis so noise does not chatter the output. The output is a normal rule output, so it can feed a TOGGLE, a PULSE, or a bool.
{ "type": "ANALOG", "input": "A2", "axis": "THROTTLE", "min": 9800, "max": 41200, "filter": 3, "curve": 1.4 },
{ "type": "THRESHOLD", "input": "THROTTLE", "output": "B41", "below": 500 }Analog values are never stored in NVM; the sensor is read again at boot. An axis driven by ANALOG cannot use store and cannot be the target of AXIS_INC or AXIS_DEC.
Bools and axes
Bools are named booleans for toggle states, and they are what the desktop app calls Variables. Axes are the eight HID outputs (X, Y, Z, Rx, Ry, Rz, Slider, Dial), 16 bit and centred at 32767. Both can be marked to persist, in which case their value is stored in NVM and survives a power cycle. A toggle that comes back on in the same state after you unplug the box is a store flag away. The one exception is an axis driven by an ANALOG rule, which is re-read from the sensor at boot instead.
The backlight is driven from an axis, two ways. Give any HID axis "backlight": trueand it dims the panel alongside its normal report, which is what the app's Backlight checkbox sets and what a volume knob that also dims the panel uses. Set an axis output to BACKLIGHT instead and it drives the backlight PWM without reporting anything to the sim.
Device settings
The device block carries the USB product name (32 characters at most), the product ID (0xF000 by default; 0x80F4is CircuitPython's own and is refused), the debounce filter in milliseconds (10 by default), and how often the box sends a keep-alive HID report when nothing is happening. Name and product ID are applied in boot.py, so a change only shows up after the box is unplugged and plugged back in.
The serial protocol
Alongside the joystick, the box exposes a USB CDC serial port. Commands are line delimited JSON, which is how the desktop appreads and writes config, streams live state, and pushes firmware. The commands cover connection tests, device info, reading and validating config, state snapshots and live streaming, file transfer, staged firmware updates, reboot, and entering the UF2 bootloader. A request may carry an id that the reply echoes, and device info includes the board's pin list and which of those pins can be analog, which is how the app knows what to validate against.
Firmware updates are staged and transactional since firmware 2.6, with power-loss-safe installs and verified transfers. Update packages deliberately leave out config.json, so installing an update can never overwrite your own configuration.
boot.py, code.py, config.json, and the lib/ folder to the CIRCUITPY drive. After the first boot that drive is disabled, so to get it back you reflash CircuitPython over BOOTSEL or use the REPL on the console serial port.The full reference, including every rule field and the serial command list, is in the firmware repository. It is MIT licensed.