Built-in project · SparkFun Pro Micro

Macro pad for streamers and coders on a SparkFun Pro Micro

A Pro Micro that plugs in as a keyboard: twelve keys of shortcuts, a volume knob, three layers with a lamp each and an OLED, with every keystroke it sends shown in the Serial Monitor. The whole build, a SparkFun Pro Micro and 9 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.

The parts retail for about $22. On Mokxi they're free.

Macro pad for streamers and coders · SparkFun Pro Microlive0.000 s 0.00x
Press Run, click a key, then press the knob to change layer
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
// Macro pad: twelve keys, a volume knob, three layers and a screen.
//
// A Pro Micro's ATmega32U4 is its own USB chip, so it can plug in as a keyboard.
// Every key here sends a shortcut, a media key or a line of text, and the
// knob turns the computer's volume. Press the knob to change layer:
//
//   CODE    green lamp   editor shortcuts (Ctrl+C, Ctrl+S, F5...)
//   STREAM  red lamp     F13 to F24, keys no app uses, ready to bind in OBS
//   MEDIA   blue lamp    play, pause, mute, lock, and three typed snippets
//
// The shortcuts are the Windows ones. On a Mac, swap CTRL for GUI (Cmd).
//
// Wiring (SparkFun Pro Micro, or any ATmega32U4 board):
//   keypad R1 R2 R3 R4 -> A2 A1 A0 15    keypad C1 C2 C3 -> 14 16 10
//   knob CLK -> 0, DT -> 1, SW -> 4      OLED SDA -> 2, SCL -> 3
//   lamps CODE 7, STREAM 8, MEDIA 9, each through 220 ohms
//
// Pins 0 and 1 carry interrupts, so the knob never loses a click while the
// screen is busy.
//
// In Mokxi there is no computer on the other end of the cable, so every
// report the board would send shows up in the Serial Monitor instead:
// "[Keyboard] press Ctrl" and "[Consumer] type Volume Up". The sketch adds
// one plain line per action so you can read what each key did.

#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
// Four keys held at once is plenty for a macro pad, and each one tracked
// costs RAM, which the screen has most of.
#define LIST_MAX 4
#include <Keypad.h>
#include <Encoder.h>
#include <HID-Project.h>

// ---- the keys ---------------------------------------------------------------

const byte ROWS = 4;
const byte COLS = 3;
char keys[ROWS][COLS] = {
  {'1', '2', '3'},
  {'4', '5', '6'},
  {'7', '8', '9'},
  {'*', '0', '#'},
};
byte rowPins[ROWS] = {A2, A1, A0, 15};
byte colPins[COLS] = {14, 16, 10};
Keypad pad = Keypad(makeKeymap(keys), rowPins, colPins, ROWS, COLS);

Encoder knob(0, 1);
const uint8_t KNOB_BUTTON = 4;
const uint8_t LAMPS[3] = {7, 8, 9};

Adafruit_SSD1306 oled(128, 32, &Wire, -1);

// ---- what each key does -------------------------------------------------------

// Modifier bits for a shortcut.
const uint8_t CTRL = 1;
const uint8_t SHIFT = 2;
const uint8_t ALT = 4;
const uint8_t GUI = 8;

enum Kind : uint8_t { COMBO, MEDIA, TEXT };

struct Action {
  char label[9];
  uint8_t kind;
  uint8_t mods;
  uint16_t code;  // a key for COMBO, a media usage for MEDIA, a snippet for TEXT
};

// Kept in flash with PROGMEM: 36 actions would fill a third of the RAM.
const Action ACTIONS[3][12] PROGMEM = {
  {
    {"Copy", COMBO, CTRL, 'c'},
    {"Paste", COMBO, CTRL, 'v'},
    {"Undo", COMBO, CTRL, 'z'},
    {"Save", COMBO, CTRL, 's'},
    {"Find", COMBO, CTRL, 'f'},
    {"Comment", COMBO, CTRL, '/'},
    {"Run", COMBO, 0, KEY_F5},
    {"Terminal", COMBO, CTRL, '`'},
    {"Format", COMBO, SHIFT | ALT, 'f'},
    {"Line up", COMBO, ALT, KEY_UP_ARROW},
    {"Line dn", COMBO, ALT, KEY_DOWN_ARROW},
    {"Commands", COMBO, CTRL | SHIFT, 'p'},
  },
  {
    {"Scene 1", COMBO, 0, KEY_F13},
    {"Scene 2", COMBO, 0, KEY_F14},
    {"Scene 3", COMBO, 0, KEY_F15},
    {"Mic mute", COMBO, 0, KEY_F16},
    {"Camera", COMBO, 0, KEY_F17},
    {"BRB", COMBO, 0, KEY_F18},
    {"Go live", COMBO, 0, KEY_F19},
    {"Record", COMBO, 0, KEY_F20},
    {"Clip", COMBO, 0, KEY_F21},
    {"Marker", COMBO, 0, KEY_F22},
    {"Chat", COMBO, 0, KEY_F23},
    {"Alert", COMBO, 0, KEY_F24},
  },
  {
    {"Previous", MEDIA, 0, MEDIA_PREVIOUS},
    {"Play", MEDIA, 0, MEDIA_PLAY_PAUSE},
    {"Next", MEDIA, 0, MEDIA_NEXT},
    {"Mute", MEDIA, 0, MEDIA_VOLUME_MUTE},
    {"Lock", COMBO, GUI, 'l'},
    {"Desktop", COMBO, GUI, 'd'},
    {"git st", TEXT, 0, 0},
    {"git pull", TEXT, 0, 1},
    {"LGTM", TEXT, 0, 2},
    {"Snip", COMBO, GUI | SHIFT, 's'},
    {"Browser", MEDIA, 0, CONSUMER_BROWSER_HOME},
    {"Stop", MEDIA, 0, MEDIA_STOP},
  },
};

const char SNIP0[] PROGMEM = "git status\n";
const char SNIP1[] PROGMEM = "git pull\n";
const char SNIP2[] PROGMEM = "LGTM, thank you!";
const char *const SNIPPETS[3] PROGMEM = {SNIP0, SNIP1, SNIP2};

const char LAYER_NAMES[3][7] PROGMEM = {"CODE", "STREAM", "MEDIA"};

// ---- state --------------------------------------------------------------------

uint8_t layer = 0;
char shown[22] = "Ready";   // the screen's second line
long knobAt = 0;            // the encoder count already acted on
bool buttonDown = false;
unsigned long buttonAt = 0;

void drawScreen();

// ---- words for the Serial Monitor and the screen -----------------------------

void layerName(char *out, uint8_t n) {
  strcpy_P(out, LAYER_NAMES[n]);
}

// "Ctrl+Shift+P", "F13", "Alt+Up": the shortcut as printed on a cheat sheet.
void comboName(char *out, uint8_t mods, uint16_t code) {
  out[0] = '\0';
  if (mods & CTRL) strcat(out, "Ctrl+");
  if (mods & SHIFT) strcat(out, "Shift+");
  if (mods & ALT) strcat(out, "Alt+");
  if (mods & GUI) strcat(out, "Win+");
  char key[5] = {0};
  if (code >= 'a' && code <= 'z') {
    key[0] = (char)(code - 'a' + 'A');
  } else if (code > ' ' && code < 0x7F) {
    key[0] = (char)code;
  } else if (code >= KEY_F1 && code <= KEY_F12) {
    key[0] = 'F';
    itoa(code - KEY_F1 + 1, key + 1, 10);
  } else if (code >= KEY_F13 && code <= KEY_F24) {
    key[0] = 'F';
    itoa(code - KEY_F13 + 13, key + 1, 10);
  } else if (code == KEY_UP_ARROW) {
    strcpy(key, "Up");
  } else if (code == KEY_DOWN_ARROW) {
    strcpy(key, "Down");
  }
  strcat(out, key);
}

// ---- doing it -----------------------------------------------------------------

void sendCombo(uint8_t mods, uint16_t code) {
  if (mods & CTRL) Keyboard.press(KEY_LEFT_CTRL);
  if (mods & SHIFT) Keyboard.press(KEY_LEFT_SHIFT);
  if (mods & ALT) Keyboard.press(KEY_LEFT_ALT);
  if (mods & GUI) Keyboard.press(KEY_LEFT_GUI);
  Keyboard.press((uint8_t)code);
  delay(5);
  Keyboard.releaseAll();
}

void typeSnippet(uint8_t n) {
  const char *text = (const char *)pgm_read_ptr(&SNIPPETS[n]);
  for (char c = (char)pgm_read_byte(text); c != '\0'; c = (char)pgm_read_byte(++text)) {
    Keyboard.write((uint8_t)c);
  }
}

void runKey(uint8_t index, char keyChar) {
  Action a;
  memcpy_P(&a, &ACTIONS[layer][index], sizeof a);

  char what[20];
  if (a.kind == COMBO) {
    comboName(what, a.mods, a.code);
  } else if (a.kind == MEDIA) {
    strcpy(what, "media key");
  } else {
    strcpy(what, "types text");
  }

  char name[7];
  layerName(name, layer);
  Serial.print(name);
  Serial.print("  key ");
  Serial.print(keyChar);
  Serial.print("  ");
  Serial.print(what);
  Serial.print("  ");
  Serial.println(a.label);

  if (a.kind == COMBO) {
    sendCombo(a.mods, a.code);
  } else if (a.kind == MEDIA) {
    Consumer.write(a.code);
  } else {
    typeSnippet((uint8_t)a.code);
  }

  shown[0] = keyChar;
  shown[1] = ' ';
  strcpy(shown + 2, a.label);
  drawScreen();
}

void showLayer() {
  for (uint8_t i = 0; i < 3; i++) {
    digitalWrite(LAMPS[i], i == layer ? HIGH : LOW);
  }
  char name[7];
  layerName(name, layer);
  Serial.print("Layer ");
  Serial.print(layer + 1);
  Serial.print(": ");
  Serial.println(name);
  strcpy(shown, "Layer ");
  strcat(shown, name);
  drawScreen();
}

// Only what changed is redrawn: the panel's driver sends just the blocks
// whose pixels moved, so a short redraw keeps the knob and keys responsive.
void drawScreen() {
  char name[7];
  layerName(name, layer);
  oled.setTextColor(SSD1306_WHITE);
  oled.fillRect(0, 0, 128, 16, SSD1306_BLACK);
  oled.setTextSize(2);
  oled.setCursor(0, 0);
  oled.print(name);
  // One box per layer, the current one filled.
  for (uint8_t i = 0; i < 3; i++) {
    int x = 96 + i * 11;
    if (i == layer) {
      oled.fillRect(x, 3, 8, 8, SSD1306_WHITE);
    } else {
      oled.drawRect(x, 3, 8, 8, SSD1306_WHITE);
    }
  }
  oled.fillRect(0, 22, 128, 10, SSD1306_BLACK);
  oled.setTextSize(1);
  oled.setCursor(0, 22);
  oled.print(shown);
  oled.display();
}

// ---- the knob -------------------------------------------------------------------

void turned(ConsumerKeycode key, const char *words) {
  Serial.print("knob  ");
  Serial.println(words);
  Consumer.write(key);
  strcpy(shown, words);
  drawScreen();
}

void readKnob() {
  // The library counts four steps for every click of a KY-040.
  long now = knob.read();
  while (now - knobAt >= 4) {
    knobAt += 4;
    turned(MEDIA_VOLUME_UP, "Volume up");
  }
  while (knobAt - now >= 4) {
    knobAt -= 4;
    turned(MEDIA_VOLUME_DOWN, "Volume down");
  }
}

void readKnobButton() {
  bool down = digitalRead(KNOB_BUTTON) == LOW;
  // A change counts at once, then the pin is ignored for 30 ms of bounce.
  if (down == buttonDown || millis() - buttonAt < 30) {
    return;
  }
  buttonDown = down;
  buttonAt = millis();
  if (down) {
    layer = (uint8_t)((layer + 1) % 3);
    showLayer();
  }
}

// ---- setup and loop ---------------------------------------------------------------

void setup() {
  Serial.begin(115200);
  pinMode(KNOB_BUTTON, INPUT_PULLUP);
  for (uint8_t i = 0; i < 3; i++) {
    pinMode(LAMPS[i], OUTPUT);
  }
  Keyboard.begin();
  Consumer.begin();
  oled.begin(SSD1306_SWITCHCAPVCC, 0x3C);
  Wire.setClock(400000);  // fast mode: the screen redraws in a quarter of the time
  Serial.println("Macro pad ready.");
  showLayer();
}

void loop() {
  readKnob();
  readKnobButton();
  if (!pad.getKeys()) {
    return;
  }
  for (byte i = 0; i < LIST_MAX; i++) {
    if (pad.key[i].stateChanged && pad.key[i].kstate == PRESSED) {
      runKey((uint8_t)pad.key[i].kcode, pad.key[i].kchar);
    }
  }
}

Parts list

11 parts, plus the jumper wires. They retail for about $22; on Mokxi they're free.

How it is wired

21 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 pin DT
  • SparkFun Pro Micro pin 0; Rotary encoder, KY-040 pin CLK
  • Ground: SparkFun Pro Micro pin GND; OLED display, 128x64 pin GND
  • SparkFun Pro Micro pin 2; OLED display, 128x64 pin SDA
  • SparkFun Pro Micro pin 3; OLED display, 128x64 pin SCL
  • SparkFun Pro Micro pin 4; Rotary encoder, KY-040 pin SW
  • SparkFun Pro Micro pin 7; LED, green pin A
  • SparkFun Pro Micro pin 8; LED, red pin A
  • SparkFun Pro Micro pin 9; LED, blue pin A
  • Ground: SparkFun Pro Micro pin GND; Rotary encoder, KY-040 pin GND; Resistor, 220 Ω (1) pin 2; Resistor, 220 Ω (2) pin 2; Resistor, 220 Ω (3) pin 2
  • SparkFun Pro Micro pin VCC; Rotary encoder, KY-040 pin VCC; OLED display, 128x64 pin VCC
  • SparkFun Pro Micro pin A2; Keypad, 4x3 telephone pin R1
  • SparkFun Pro Micro pin A1; Keypad, 4x3 telephone pin R2
  • SparkFun Pro Micro pin A0; Keypad, 4x3 telephone pin R3
  • SparkFun Pro Micro pin 15; Keypad, 4x3 telephone pin R4
  • SparkFun Pro Micro pin 14; Keypad, 4x3 telephone pin C1
  • SparkFun Pro Micro pin 16; Keypad, 4x3 telephone pin C2
  • SparkFun Pro Micro pin 10; Keypad, 4x3 telephone pin C3
  • LED, green pin C; Resistor, 220 Ω (1) pin 1
  • LED, red pin C; Resistor, 220 Ω (2) pin 1
  • LED, blue 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.