Built-in project · SparkFun Pro Micro

Sim racing button box on a SparkFun Pro Micro

Two encoders, an ignition key with a start button, three toggles, four buttons and three LEDs on a Pro Micro, sent to the game as one USB controller. The whole build, a SparkFun Pro Micro and 17 more parts, runs here in your browser on the firmware below; open it in the editor to change the wiring or the code and run it again.

Teaching? Assign it to your class in one click.

Advanced Runs in your browser. Free, and no account needed.

Sim racing button box · SparkFun Pro Microlive0.000 s 0.00x
Press Run, then turn the knobs and flip the switches
The circuit itself, running here on the simulator. Press what can be pressed; click anything else to open it in the editor.

The code

The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.

sketch.ino · SparkFun Pro Micro
// Sim racing button box: a Pro Micro as a USB game controller.
//
// What is on the panel, and where it goes. Every switch has one leg on GND
// and the other on a pin with the internal pull-up on, so a closed switch
// reads LOW and nothing needs a resistor except the LEDs.
//
//   TC knob     encoder, A on 3, B on 4, push on 5
//   ABS knob    encoder, A on 1, B on 0, push on 2
//   ignition    key switch (a slide switch here) on 6
//   START       momentary on A3, works only with the ignition on
//   wipers      toggle on 8          lights   toggle on 9
//   pit limiter guarded toggle on 7
//   PIT  RADIO  FLASH  DRS   momentary buttons on A2, A1, A0 and 15
//   LEDs        ignition 10, pit limiter 16, lights 14
//
// The whole box is described by the tables in the block below. Add a row to
// add a switch: give it a pin, say what kind of switch it is and which
// controller button it drives. Nothing else in the sketch needs to change.
//
// Button numbers count from 0, as the Joystick library does. Windows' game
// controller panel and most games count from 1, so button 0 here is
// "Button 1" there.
//
// Why toggles send pulses. A game binds a control to a button press: one
// press, one wiper step or one headlight flash. A latching toggle that holds
// its button down for an hour looks to the game like one very long press, and
// the next flip, which releases it, does nothing at all. So each flip of a
// PULSE toggle sends one short press, and a PULSE_BOTH toggle sends a press
// on one button when it goes on and on another when it goes off, which is how
// you bind a toggle to a game that has separate "on" and "off" commands. A
// HOLD toggle keeps its button down while it is on, for the games and tools
// (SimHub, joystick mappers) that read a held button as a state.
//
// Encoders are pulses too: each click is one press of the clockwise or the
// counterclockwise button, which is how traction control, ABS and brake bias
// are bound in most racing games. Spin a knob fast and the clicks are queued
// and played out one pulse at a time, so the game sees every one of them.
//
// In Mokxi: drag or scroll the knobs to turn them and click them to push,
// click the slide switches to flip them and hold the round buttons. A slide
// switch is on when its knob is over the leg wired to GND. The
// Serial Monitor shows each event, and a [Joystick] line with the report the
// box would send over USB. On a real Pro Micro those reports go to the PC and
// the box shows up as a game controller.

#include <Joystick.h>

// ---------------------------------------------------------------------------
// Your build. Everything you would change for your own box is in this block.
// ---------------------------------------------------------------------------

// How long a pulse holds its button down, and the gap before the next one.
// A game reads the controller once a frame, so a pulse has to outlast a few
// frames. 100 ms is a safe start; shorten it if your game keeps up.
const unsigned long PULSE_MS = 100;
const unsigned long GAP_MS = 40;

// How long a switch is ignored after it changes, to throw away contact bounce.
const unsigned long LOCK_MS = 10;

enum Kind : uint8_t {
  MOMENTARY,   // held while the button is held
  HOLD,        // a toggle that holds its button down while it is on
  PULSE,       // a toggle that sends one press each time it is flipped
  PULSE_BOTH,  // a toggle: a press on `button` going on, on `offButton` going off
  IGNITION,    // a key switch: a press going on and on `offButton` going off
  STARTER,     // momentary, but only while the ignition is on
};

const uint8_t NONE = 0xFF;

struct Switch {
  uint8_t pin;
  Kind kind;
  uint8_t button;
  uint8_t offButton;
  const char *name;
};

const Switch SWITCHES[] = {
  {6, IGNITION, 0, 1, "ignition"},
  {A3, STARTER, 2, NONE, "start"},
  {8, PULSE, 3, NONE, "wipers"},
  {9, PULSE_BOTH, 4, 5, "lights"},
  {7, HOLD, 6, NONE, "pit limiter"},
  {A2, MOMENTARY, 7, NONE, "pit request"},
  {A1, MOMENTARY, 8, NONE, "radio"},
  {A0, MOMENTARY, 9, NONE, "flash"},
  {15, MOMENTARY, 10, NONE, "DRS"},
};

struct Knob {
  uint8_t pinA;
  uint8_t pinB;
  uint8_t pinPush;
  uint8_t upButton;    // clockwise
  uint8_t downButton;  // counterclockwise
  uint8_t pushButton;
  const char *name;
};

const Knob KNOBS[] = {
  {3, 4, 5, 11, 12, 13, "TC"},
  {1, 0, 2, 14, 15, 16, "ABS"},
};

// LEDs and what lights them: a switch's state, by its name in SWITCHES.
struct Lamp {
  uint8_t pin;
  const char *follows;
};

const Lamp LAMPS[] = {
  {10, "ignition"},
  {16, "pit limiter"},
  {14, "lights"},
};

// One more than the highest button number used above.
const uint8_t BUTTON_COUNT = 17;

// ---------------------------------------------------------------------------
// The rest is the box itself.
// ---------------------------------------------------------------------------

const uint8_t SWITCH_COUNT = sizeof(SWITCHES) / sizeof(SWITCHES[0]);
const uint8_t KNOB_COUNT = sizeof(KNOBS) / sizeof(KNOBS[0]);
const uint8_t LAMP_COUNT = sizeof(LAMPS) / sizeof(LAMPS[0]);

// A game controller with buttons and nothing else: no hat switch, no axes.
Joystick_ Joystick(JOYSTICK_DEFAULT_REPORT_ID, JOYSTICK_TYPE_GAMEPAD, BUTTON_COUNT, 0,
                   false, false, false, false, false, false, false, false, false, false, false);

// ---- pulses ----------------------------------------------------------------
// Each button can have presses waiting. A pulse holds the button for
// PULSE_MS, lets go for GAP_MS, and then plays the next one if there is one.

uint8_t waiting[BUTTON_COUNT];
unsigned long pulseAt[BUTTON_COUNT];
uint8_t pulsePhase[BUTTON_COUNT];  // 0 idle, 1 held, 2 in the gap
bool held[BUTTON_COUNT];           // buttons held by a switch, not by a pulse

void queuePulse(uint8_t button) {
  if (button == NONE || button >= BUTTON_COUNT) return;
  if (waiting[button] < 20) waiting[button]++;
}

void runPulses(unsigned long now) {
  for (uint8_t b = 0; b < BUTTON_COUNT; b++) {
    if (pulsePhase[b] == 0 && waiting[b] > 0) {
      waiting[b]--;
      pulsePhase[b] = 1;
      pulseAt[b] = now;
    } else if (pulsePhase[b] == 1 && now - pulseAt[b] >= PULSE_MS) {
      pulsePhase[b] = 2;
      pulseAt[b] = now;
    } else if (pulsePhase[b] == 2 && now - pulseAt[b] >= GAP_MS) {
      pulsePhase[b] = 0;
    }
  }
}

// ---- switches --------------------------------------------------------------

bool closed[SWITCH_COUNT];
unsigned long changedAt[SWITCH_COUNT];
bool ignitionOn = false;

void say(const char *name, const char *what) {
  Serial.print(name);
  Serial.print(' ');
  Serial.println(what);
}

// A switch has just changed state. Decide what the game should see.
void switched(uint8_t i, bool on) {
  const Switch &s = SWITCHES[i];
  switch (s.kind) {
    case MOMENTARY:
      held[s.button] = on;
      if (on) say(s.name, "pressed");
      break;
    case HOLD:
      held[s.button] = on;
      say(s.name, on ? "on (button held)" : "off");
      break;
    case PULSE:
      queuePulse(s.button);
      say(s.name, on ? "on (one press)" : "off (one press)");
      break;
    case PULSE_BOTH:
      queuePulse(on ? s.button : s.offButton);
      say(s.name, on ? "on" : "off");
      break;
    case IGNITION:
      ignitionOn = on;
      queuePulse(on ? s.button : s.offButton);
      say(s.name, on ? "on" : "off");
      break;
    case STARTER:
      // Held only while the key is on, as in the car. With the key off the
      // button does nothing at all, so a knock on it cannot start the engine.
      held[s.button] = on && ignitionOn;
      if (on) say(s.name, ignitionOn ? "cranking" : "ignored: ignition is off");
      break;
  }
}

void readSwitches(unsigned long now) {
  for (uint8_t i = 0; i < SWITCH_COUNT; i++) {
    bool on = digitalRead(SWITCHES[i].pin) == LOW;
    if (on == closed[i] || now - changedAt[i] < LOCK_MS) continue;
    closed[i] = on;
    changedAt[i] = now;
    switched(i, on);
  }
  // Turning the key off while START is held lets the starter go at once.
  for (uint8_t i = 0; i < SWITCH_COUNT; i++) {
    if (SWITCHES[i].kind == STARTER && !ignitionOn) held[SWITCHES[i].button] = false;
  }
}

// ---- encoders --------------------------------------------------------------
// The two contacts of an encoder walk a four-state Gray code, one way for
// clockwise and the other way back. Every valid step adds or takes one; a
// bounce adds one and takes it straight back. When the contacts come to rest
// at the detent (both open) a net count of four is one click.

const int8_t QUARTER[16] = {0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0};

uint8_t knobState[KNOB_COUNT];
int8_t knobSteps[KNOB_COUNT];
bool knobPushed[KNOB_COUNT];
unsigned long knobPushedAt[KNOB_COUNT];

uint8_t contacts(const Knob &k) {
  return (uint8_t)((digitalRead(k.pinA) == HIGH ? 2 : 0) | (digitalRead(k.pinB) == HIGH ? 1 : 0));
}

void readKnobs(unsigned long now) {
  for (uint8_t i = 0; i < KNOB_COUNT; i++) {
    const Knob &k = KNOBS[i];
    uint8_t state = contacts(k);
    if (state != knobState[i]) {
      knobSteps[i] += QUARTER[(knobState[i] << 2) | state];
      knobState[i] = state;
      if (state == 3) {  // at rest on a detent
        if (knobSteps[i] >= 2) {
          queuePulse(k.upButton);
          say(k.name, "+1");
        } else if (knobSteps[i] <= -2) {
          queuePulse(k.downButton);
          say(k.name, "-1");
        }
        knobSteps[i] = 0;
      }
    }

    bool pushed = digitalRead(k.pinPush) == LOW;
    if (pushed != knobPushed[i] && now - knobPushedAt[i] >= LOCK_MS) {
      knobPushed[i] = pushed;
      knobPushedAt[i] = now;
      held[k.pushButton] = pushed;
      if (pushed) say(k.name, "pushed");
    }
  }
}

// ---- LEDs ------------------------------------------------------------------

uint8_t lampSwitch[LAMP_COUNT];  // which switch each LED follows, found once

void findLampSwitches() {
  for (uint8_t l = 0; l < LAMP_COUNT; l++) {
    lampSwitch[l] = NONE;
    for (uint8_t i = 0; i < SWITCH_COUNT; i++) {
      if (strcmp(SWITCHES[i].name, LAMPS[l].follows) == 0) lampSwitch[l] = i;
    }
  }
}

void showLamps() {
  for (uint8_t l = 0; l < LAMP_COUNT; l++) {
    bool on = lampSwitch[l] != NONE && closed[lampSwitch[l]];
    digitalWrite(LAMPS[l].pin, on ? HIGH : LOW);
  }
}

// ---- the report ------------------------------------------------------------

void sendReport() {
  for (uint8_t b = 0; b < BUTTON_COUNT; b++) {
    Joystick.setButton(b, held[b] || pulsePhase[b] == 1);
  }
  // With auto-send off, sendState() is the one report this pass; the library
  // sends nothing when nothing changed.
  Joystick.sendState();
}

void setup() {
  for (uint8_t i = 0; i < SWITCH_COUNT; i++) pinMode(SWITCHES[i].pin, INPUT_PULLUP);
  for (uint8_t i = 0; i < KNOB_COUNT; i++) {
    pinMode(KNOBS[i].pinA, INPUT_PULLUP);
    pinMode(KNOBS[i].pinB, INPUT_PULLUP);
    pinMode(KNOBS[i].pinPush, INPUT_PULLUP);
    knobState[i] = contacts(KNOBS[i]);
  }
  for (uint8_t l = 0; l < LAMP_COUNT; l++) pinMode(LAMPS[l].pin, OUTPUT);
  findLampSwitches();

  Serial.begin(115200);
  Serial.println("Button box ready: turn and push the knobs, flip the switches.");

  // A toggle that is already on at power-up is a state, not a flip: take it
  // as it is without sending a pulse, so plugging the box in changes nothing
  // in the game.
  delay(20);
  for (uint8_t i = 0; i < SWITCH_COUNT; i++) {
    closed[i] = digitalRead(SWITCHES[i].pin) == LOW;
    const Switch &s = SWITCHES[i];
    if (s.kind == IGNITION) ignitionOn = closed[i];
    if (s.kind == HOLD) held[s.button] = closed[i];
  }

  Joystick.begin(false);
}

void loop() {
  unsigned long now = millis();
  readSwitches(now);
  readKnobs(now);
  runPulses(now);
  showLamps();
  sendReport();
}

Parts list

19 parts, plus the jumper wires. Every one is in the editor's parts bin.

How it is wired

24 connections, pin by pin, read from the circuit itself. Each line is one set of pins joined together, by a jumper wire or a breadboard strip.

  • SparkFun Pro Micro pin 1; Rotary encoder, KY-040 (1) pin CLK
  • SparkFun Pro Micro pin 0; Rotary encoder, KY-040 (1) pin DT
  • Ground: SparkFun Pro Micro pin GND; Slide switch (1) pin 3; Slide switch (2) pin 3; Slide switch (3) pin 3; Slide switch (4) pin 3; Arcade button (1) pin 2; Arcade button (2) pin 2; Arcade button (3) pin 2; Arcade button (4) pin 2; Arcade button (5) pin 2; Rotary encoder, KY-040 (2) pin GND; Resistor, 220 Ω (1) pin 2; Resistor, 220 Ω (2) pin 2; Resistor, 220 Ω (3) pin 2
  • Ground: SparkFun Pro Micro pin GND; Rotary encoder, KY-040 (1) pin GND
  • SparkFun Pro Micro pin 2; Rotary encoder, KY-040 (1) pin SW
  • SparkFun Pro Micro pin 3; Rotary encoder, KY-040 (2) pin CLK
  • SparkFun Pro Micro pin 4; Rotary encoder, KY-040 (2) pin DT
  • SparkFun Pro Micro pin 5; Rotary encoder, KY-040 (2) pin SW
  • SparkFun Pro Micro pin 6; Slide switch (4) pin 2
  • SparkFun Pro Micro pin 7; Slide switch (3) pin 2
  • SparkFun Pro Micro pin 8; Slide switch (2) pin 2
  • SparkFun Pro Micro pin 9; Slide switch (1) pin 2
  • SparkFun Pro Micro pin VCC; Rotary encoder, KY-040 (1) pin VCC; Rotary encoder, KY-040 (2) pin VCC
  • SparkFun Pro Micro pin A3; Arcade button (1) pin 1
  • SparkFun Pro Micro pin A2; Arcade button (2) pin 1
  • SparkFun Pro Micro pin A1; Arcade button (3) pin 1
  • SparkFun Pro Micro pin A0; Arcade button (4) pin 1
  • SparkFun Pro Micro pin 15; Arcade button (5) pin 1
  • SparkFun Pro Micro pin 14; LED, green pin A
  • SparkFun Pro Micro pin 16; LED, blue pin A
  • SparkFun Pro Micro pin 10; LED, yellow pin A
  • LED, yellow pin C; Resistor, 220 Ω (1) pin 1
  • LED, blue pin C; Resistor, 220 Ω (2) pin 1
  • LED, green pin C; Resistor, 220 Ω (3) pin 1

Change it and keep it

Open it in the editor, change the circuit or the code, and keep your version in a free account.