Key matrix numpad on a SparkFun Pro Micro
Twelve switches in a 4 x 3 matrix with a diode on every key, three layers, an encoder and per-key RGB on a Pro Micro, typed as a USB keyboard. The whole build, a SparkFun Pro Micro and 26 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 code
The firmware exactly as the editor opens it. Change a line there and press Run: it compiles in the browser.
// Matrix numpad: twelve keys, twelve diodes, layers, a knob and per-key RGB.
//
// This is the firmware of a small custom keyboard, written out in one sketch
// so every part of it can be read: the matrix scan, per-key debounce, a
// layered keymap, an encoder and reactive lighting. It is not QMK or KMK.
// Those are complete keyboard firmwares with far more in them (tap-hold
// keys, combos, a configurator, split support, NKRO over USB); this is the
// same set of ideas small enough to read in one sitting, and to try before
// you solder.
//
// # The matrix
//
// Twelve keys on twelve pins would use twelve pins. A matrix puts them on a
// grid of rows and columns instead, so 4 rows and 3 columns need 7 pins, and
// a 4 x 12 ortholinear board needs 16 for 48 keys. Every key joins its row to
// its column. To read the grid, the sketch drives one row LOW, leaves the
// others floating, and reads every column with its pull-up on: a column that
// reads LOW has a pressed key in that row. Then the next row.
//
// rows R1 to R4 on pins 2, 3, 4 and 5 (driven LOW one at a time)
// columns C1 to C3 on pins 8, 7 and 6 (inputs with pull-ups)
//
// Each key has a diode in series, anode at the switch, cathode (the band)
// toward the row: QMK calls this COL2ROW. Without diodes, holding three keys
// on the corners of a rectangle makes the fourth corner read as pressed too,
// because current finds a way around through the three closed switches. That
// is ghosting. The diode only lets current flow one way, so that path is
// blocked and every key reads true however many are held.
//
// The USB report is a separate limit: the Keyboard library sends the boot
// keyboard report, which holds six keys at once (6KRO). The matrix itself
// has no such limit.
//
// # The rest of the board
//
// encoder A on A1, B on A2, push on A3 per-key RGB WS2812B strip on 1
//
// In Mokxi: click and hold the keys, turn the knob by dragging or scrolling,
// and click it. The Serial Monitor shows each layer change and what the
// keyboard would type, one [Keyboard] or [Mouse] line per report.
#include <Adafruit_NeoPixel.h>
#include <Keyboard.h>
#include <Mouse.h>
// ---------------------------------------------------------------------------
// Your board.
// ---------------------------------------------------------------------------
const uint8_t ROWS = 4;
const uint8_t COLS = 3;
const uint8_t ROW_PINS[ROWS] = {2, 3, 4, 5};
const uint8_t COL_PINS[COLS] = {8, 7, 6};
const uint8_t ENC_A = A1;
const uint8_t ENC_B = A2;
const uint8_t ENC_PUSH = A3;
const uint8_t LED_PIN = 1;
// A key change must hold this long before it counts, which is QMK's default.
const unsigned long DEBOUNCE_MS = 5;
// Keycodes. A plain key is its Keyboard.h code or character; the high bits
// add modifiers, so CTRL('z') is Ctrl+Z. MO(n) holds layer n while the key is
// held, TG(n) toggles it, and ____ falls through to the layer below, as in
// QMK's keymaps.
typedef uint16_t Key;
#define CTRL(k) ((Key)(0x0100 | (k)))
#define SHIFT(k) ((Key)(0x0200 | (k)))
#define MO(n) ((Key)(0xF000 | (n)))
#define TG(n) ((Key)(0xF100 | (n)))
#define ____ ((Key)0xFFFF)
#define NOKEY ((Key)0x0000)
// Higher layers win, so NAV sits above EDIT: hold the bottom-left key while
// EDIT is on and NAV's TG(EDIT) is there to turn EDIT off again.
enum Layer : uint8_t { NUM, EDIT, NAV, LAYERS };
const char *const LAYER_NAMES[LAYERS] = {"NUM", "EDIT", "NAV"};
// Laid out as the keys are, top row first.
const Key KEYMAP[LAYERS][ROWS][COLS] = {
{ // NUM, the base layer
{KEY_KP_7, KEY_KP_8, KEY_KP_9},
{KEY_KP_4, KEY_KP_5, KEY_KP_6},
{KEY_KP_1, KEY_KP_2, KEY_KP_3},
{MO(NAV), KEY_KP_0, KEY_KP_ENTER},
},
{ // EDIT, toggled on and off from NAV
{CTRL('z'), CTRL('y'), CTRL('s')},
{CTRL('x'), CTRL('c'), CTRL('v')},
{CTRL('a'), CTRL('f'), SHIFT(KEY_TAB)},
{MO(NAV), KEY_TAB, KEY_RETURN},
},
{ // NAV, held on the bottom-left key
{KEY_HOME, KEY_UP_ARROW, KEY_PAGE_UP},
{KEY_LEFT_ARROW, KEY_ESC, KEY_RIGHT_ARROW},
{KEY_END, KEY_DOWN_ARROW, KEY_PAGE_DOWN},
{____, KEY_BACKSPACE, TG(EDIT)},
},
};
// What the knob does on each layer: one key for each direction, or NOKEY to
// scroll the mouse wheel instead.
const Key KNOB_CW[LAYERS] = {NOKEY, CTRL('y'), KEY_RIGHT_ARROW};
const Key KNOB_CCW[LAYERS] = {NOKEY, CTRL('z'), KEY_LEFT_ARROW};
// Per-key lighting: each layer's color, and the flash a pressed key gets.
const uint32_t LAYER_COLOR[LAYERS] = {0x0030FF, 0xFF9000, 0x00FF40};
const uint8_t LAYER_LEVEL = 40; // the layer color's brightness, out of 255
// ---------------------------------------------------------------------------
// The matrix scan.
// ---------------------------------------------------------------------------
bool raw[ROWS][COLS]; // what the last scan read
bool down[ROWS][COLS]; // the debounced state the keymap sees
unsigned long changedAt[ROWS][COLS];
Key active[ROWS][COLS]; // the key each held switch pressed, kept until release
void scan(unsigned long now) {
for (uint8_t r = 0; r < ROWS; r++) {
pinMode(ROW_PINS[r], OUTPUT);
digitalWrite(ROW_PINS[r], LOW);
delayMicroseconds(30); // let the column lines settle, as QMK does
for (uint8_t c = 0; c < COLS; c++) {
bool pressed = digitalRead(COL_PINS[c]) == LOW;
if (pressed != raw[r][c]) {
raw[r][c] = pressed;
changedAt[r][c] = now;
}
}
// Let the row float again rather than driving it HIGH: two keys held in
// one column then never put a driven HIGH against a driven LOW.
pinMode(ROW_PINS[r], INPUT);
}
}
// ---------------------------------------------------------------------------
// Layers.
// ---------------------------------------------------------------------------
uint8_t heldLayers = 0; // MO layers, one bit each
uint8_t toggledLayers = 0; // TG layers
uint8_t shownLayer = 0xFF;
bool layerOn(uint8_t layer) {
return layer == NUM || ((heldLayers | toggledLayers) >> layer) & 1u;
}
uint8_t topLayer() {
for (int8_t l = LAYERS - 1; l >= 0; l--) {
if (layerOn((uint8_t)l)) return (uint8_t)l;
}
return NUM;
}
// The key a switch means now: the highest layer that is on and not ____.
Key lookup(uint8_t r, uint8_t c) {
for (int8_t l = LAYERS - 1; l >= 0; l--) {
if (!layerOn((uint8_t)l)) continue;
Key k = KEYMAP[l][r][c];
if (k != ____) return k;
}
return NOKEY;
}
void modifiers(Key k, bool press) {
if (k & 0x0100) press ? Keyboard.press(KEY_LEFT_CTRL) : Keyboard.release(KEY_LEFT_CTRL);
if (k & 0x0200) press ? Keyboard.press(KEY_LEFT_SHIFT) : Keyboard.release(KEY_LEFT_SHIFT);
}
void keyDown(Key k) {
if ((k & 0xFF00) == 0xF000) {
heldLayers |= (uint8_t)(1u << (k & 0x0F));
} else if ((k & 0xFF00) == 0xF100) {
toggledLayers ^= (uint8_t)(1u << (k & 0x0F));
} else if (k != NOKEY) {
modifiers(k, true);
Keyboard.press((uint8_t)(k & 0xFF));
}
}
void keyUp(Key k) {
if ((k & 0xFF00) == 0xF000) {
heldLayers &= (uint8_t)~(1u << (k & 0x0F));
} else if ((k & 0xFF00) == 0xF100) {
// a toggle does its work on the press
} else if (k != NOKEY) {
Keyboard.release((uint8_t)(k & 0xFF));
modifiers(k, false);
}
}
// ---------------------------------------------------------------------------
// Lighting: one WS2812B per key, in reading order, as a keyboard's per-key
// LEDs are numbered; the knob's push turns it off and on.
// ---------------------------------------------------------------------------
Adafruit_NeoPixel pixels(ROWS * COLS, LED_PIN, NEO_GRB + NEO_KHZ800);
uint8_t flash[ROWS][COLS]; // 255 the moment a key is pressed, fading to 0
bool lightsOn = true;
unsigned long litAt = 0;
uint32_t blend(uint32_t color, uint8_t level, uint8_t white) {
uint32_t out = 0;
for (uint8_t shift = 0; shift <= 16; shift += 8) {
uint32_t c = (color >> shift) & 0xFF;
uint32_t v = c * level / 255 + white;
out |= (v > 255 ? 255 : v) << shift;
}
return out;
}
void light(unsigned long now) {
if (now - litAt < 20) return; // 50 frames a second is plenty
uint8_t fade = (uint8_t)((now - litAt) / 2);
litAt = now;
uint32_t base = LAYER_COLOR[topLayer()];
for (uint8_t r = 0; r < ROWS; r++) {
for (uint8_t c = 0; c < COLS; c++) {
uint8_t f = flash[r][c];
flash[r][c] = down[r][c] ? 255 : (f > fade ? (uint8_t)(f - fade) : 0);
uint32_t color = lightsOn ? blend(base, LAYER_LEVEL, flash[r][c]) : 0;
pixels.setPixelColor(r * COLS + c, color);
}
}
pixels.show();
}
// ---------------------------------------------------------------------------
// The knob.
// ---------------------------------------------------------------------------
const int8_t QUARTER[16] = {0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0};
uint8_t knobState;
int8_t knobSteps;
bool knobDown = false;
unsigned long knobAt = 0;
void tap(Key k) {
keyDown(k);
keyUp(k);
}
void knob(unsigned long now) {
uint8_t state = (uint8_t)((digitalRead(ENC_A) == HIGH ? 2 : 0) | (digitalRead(ENC_B) == HIGH ? 1 : 0));
if (state != knobState) {
knobSteps += QUARTER[(knobState << 2) | state];
knobState = state;
if (state == 3) {
int8_t dir = knobSteps >= 2 ? 1 : (knobSteps <= -2 ? -1 : 0);
knobSteps = 0;
uint8_t layer = topLayer();
Key k = dir > 0 ? KNOB_CW[layer] : KNOB_CCW[layer];
if (dir != 0) {
if (k == NOKEY) Mouse.move(0, 0, dir); // the wheel: up for clockwise
else tap(k);
}
}
}
bool pushed = digitalRead(ENC_PUSH) == LOW;
if (pushed != knobDown && now - knobAt >= DEBOUNCE_MS) {
knobDown = pushed;
knobAt = now;
if (pushed) {
lightsOn = !lightsOn;
Serial.println(lightsOn ? "lights on" : "lights off");
}
}
}
// ---------------------------------------------------------------------------
void setup() {
for (uint8_t r = 0; r < ROWS; r++) pinMode(ROW_PINS[r], INPUT);
for (uint8_t c = 0; c < COLS; c++) pinMode(COL_PINS[c], INPUT_PULLUP);
pinMode(ENC_A, INPUT_PULLUP);
pinMode(ENC_B, INPUT_PULLUP);
pinMode(ENC_PUSH, INPUT_PULLUP);
knobState = (uint8_t)((digitalRead(ENC_A) == HIGH ? 2 : 0) | (digitalRead(ENC_B) == HIGH ? 1 : 0));
Serial.begin(115200);
Serial.println("Matrix numpad ready: hold the bottom-left key for NAV.");
pixels.begin();
Keyboard.begin();
Mouse.begin();
}
void loop() {
unsigned long now = millis();
scan(now);
// Debounce: a key whose reading has been steady for DEBOUNCE_MS and
// differs from what the keymap last saw has really changed.
for (uint8_t r = 0; r < ROWS; r++) {
for (uint8_t c = 0; c < COLS; c++) {
if (raw[r][c] == down[r][c] || now - changedAt[r][c] < DEBOUNCE_MS) continue;
down[r][c] = raw[r][c];
if (down[r][c]) {
// The key is decided on the press and kept until the release, so
// letting go of a layer key first cannot leave a key stuck down.
active[r][c] = lookup(r, c);
keyDown(active[r][c]);
} else {
keyUp(active[r][c]);
}
}
}
uint8_t layer = topLayer();
if (layer != shownLayer) {
shownLayer = layer;
Serial.print("layer ");
Serial.println(LAYER_NAMES[layer]);
}
knob(now);
light(now);
}
Parts list
31 parts, plus the jumper wires. Every one is in the editor's parts bin.
- 1 × SparkFun Pro Micro
- 4 × Half breadboard
- 12 × Pushbutton
- 12 × Diode
- 1 × Rotary encoder, KY-040
- 1 × Addressable RGB LEDs
How it is wired
35 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.
- Pushbutton (1) pin 1b; Pushbutton (4) pin 1a
- Pushbutton (1) pin 2b; Diode (1) pin A
- Diode (1) pin C; Diode (2) pin C; Diode (3) pin C; SparkFun Pro Micro pin 2
- Pushbutton (2) pin 1b; Pushbutton (5) pin 1a
- Pushbutton (2) pin 2b; Diode (2) pin A
- Pushbutton (3) pin 1b; Pushbutton (6) pin 1a
- Pushbutton (3) pin 2b; Diode (3) pin A
- Pushbutton (4) pin 1b; Pushbutton (7) pin 1a
- Pushbutton (4) pin 2b; Diode (4) pin A
- Diode (4) pin C; Diode (5) pin C; Diode (6) pin C; SparkFun Pro Micro pin 3
- Pushbutton (5) pin 1b; Pushbutton (8) pin 1a
- Pushbutton (5) pin 2b; Diode (5) pin A
- Pushbutton (6) pin 1b; Pushbutton (9) pin 1a
- Pushbutton (6) pin 2b; Diode (6) pin A
- Pushbutton (7) pin 1b; Pushbutton (10) pin 1a
- Pushbutton (7) pin 2b; Diode (7) pin A
- Diode (7) pin C; Diode (8) pin C; Diode (9) pin C; SparkFun Pro Micro pin 4
- Pushbutton (8) pin 1b; Pushbutton (11) pin 1a
- Pushbutton (8) pin 2b; Diode (8) pin A
- Pushbutton (9) pin 1b; Pushbutton (12) pin 1a
- Pushbutton (9) pin 2b; Diode (9) pin A
- Pushbutton (10) pin 1b; SparkFun Pro Micro pin 8
- Pushbutton (10) pin 2b; Diode (10) pin A
- Diode (10) pin C; Diode (11) pin C; Diode (12) pin C; SparkFun Pro Micro pin 5
- Pushbutton (11) pin 1b; SparkFun Pro Micro pin 7
- Pushbutton (11) pin 2b; Diode (11) pin A
- Pushbutton (12) pin 1b; SparkFun Pro Micro pin 6
- Pushbutton (12) pin 2b; Diode (12) pin A
- SparkFun Pro Micro pin 1; Addressable RGB LEDs pin DIN
- Ground: SparkFun Pro Micro pin GND; Addressable RGB LEDs pin VSS
- Ground: SparkFun Pro Micro pin GND; Rotary encoder, KY-040 pin GND
- SparkFun Pro Micro pin VCC; Rotary encoder, KY-040 pin VCC; Addressable RGB LEDs pin VDD
- SparkFun Pro Micro pin A3; Rotary encoder, KY-040 pin SW
- SparkFun Pro Micro pin A2; Rotary encoder, KY-040 pin DT
- SparkFun Pro Micro pin A1; Rotary encoder, KY-040 pin CLK
Change it and keep it
Open it in the editor, change the circuit or the code, and keep your version in a free account.