Community project

ESP32 Macro Control Pad

ESP32
Photo of ESP32 Macro Control Pad
Generated with AI

Kk Tt

Published October 19, 2026

The ESP32 Macro Control Pad is a programmable 9-key macro keyboard with dual rotary encoders and an OLED display. It connects via USB to send custom keystrokes, macros, and commands to a computer, with layer switching and a menu system for configuration.

This guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions for building the pad from scratch. The included Arduino firmware handles the 3x3 key matrix, rotary encoder inputs, OLED display rendering, and USB HID keyboard output, with optional Wi-Fi clock synchronization.

Wiring diagram

Gather all the parts

QtyComponent
1

SSD1306 OLED

0.96 inch 128x64 OLED display with I2C interface

1

KY-040 Rotary Encoder Module

5-pin incremental rotary encoder breakout with integrated momentary push switch. CLK and DT are the quadrature outputs; SW is the built-in push-button output and should not be modelled as a separate Push Button component.

1

KY-040 Rotary Encoder Module

5-pin incremental rotary encoder breakout with integrated momentary push switch. CLK and DT are the quadrature outputs; SW is the built-in push-button output and should not be modelled as a separate Push Button component.

1

LiPo 3.7V 1000mAh Battery

Single-cell LiPo pack, nominal 3.7 V, 1000 mAh. Default rechargeable choice for portable ESP32 / Pico projects. Pair with a TP4056 charger for safe USB recharging.

1

TP4056 Li-Ion/LiPo charger module with protection

TP4056 single-cell Li-Ion/LiPo linear charger module, 5V USB input, 1A charge current (programmable). Common variants ship with DW01 protection. Pair with battery_lipo_storage for the cell.

1

Boost Converter

Small adjustable MT3608-style DC-DC boost converter module for stepping a lower DC input up to a higher rail such as 5V, 9V, or 12V. It is a power-path module with VIN/VOUT terminals, not a GPIO peripheral.

1

Mechanical keyboard switch (R1C1)

A keyboard switch that closes when its keycap is pressed.

1

Mechanical keyboard switch (R1C2)

A keyboard switch that closes when its keycap is pressed.

1

Mechanical keyboard switch (R1C3)

A keyboard switch that closes when its keycap is pressed.

1

Mechanical keyboard switch (R2C1)

A keyboard switch that closes when its keycap is pressed.

1

Mechanical keyboard switch (R2C2)

A keyboard switch that closes when its keycap is pressed.

1

Mechanical keyboard switch (R2C3)

A keyboard switch that closes when its keycap is pressed.

1

Mechanical keyboard switch (R3C1)

A keyboard switch that closes when its keycap is pressed.

1

Mechanical keyboard switch (R3C2)

A keyboard switch that closes when its keycap is pressed.

1

Mechanical keyboard switch (R3C3)

A keyboard switch that closes when its keycap is pressed.

1

1N4148 Signal Diode

Fast small-signal switching diode in DO-35 glass package. Forward voltage ~0.6–0.7 V at low current, reverse breakdown 75 V, max forward current 300 mA, reverse recovery time 4 ns. Used here in an anti-parallel pair across the TL072 op-amp feedback loop (D1: anode→OPAMP_OUT, cathode→OPAMP_IN_NEG; D2: anode→OPAMP_IN_NEG, cathode→OPAMP_OUT) to provide soft audio clipping.

1

1N4148 Signal Diode

Fast small-signal switching diode in DO-35 glass package. Forward voltage ~0.6–0.7 V at low current, reverse breakdown 75 V, max forward current 300 mA, reverse recovery time 4 ns. Used here in an anti-parallel pair across the TL072 op-amp feedback loop (D1: anode→OPAMP_OUT, cathode→OPAMP_IN_NEG; D2: anode→OPAMP_IN_NEG, cathode→OPAMP_OUT) to provide soft audio clipping.

1

1N4148 Signal Diode

Fast small-signal switching diode in DO-35 glass package. Forward voltage ~0.6–0.7 V at low current, reverse breakdown 75 V, max forward current 300 mA, reverse recovery time 4 ns. Used here in an anti-parallel pair across the TL072 op-amp feedback loop (D1: anode→OPAMP_OUT, cathode→OPAMP_IN_NEG; D2: anode→OPAMP_IN_NEG, cathode→OPAMP_OUT) to provide soft audio clipping.

1

1N4148 Signal Diode

Fast small-signal switching diode in DO-35 glass package. Forward voltage ~0.6–0.7 V at low current, reverse breakdown 75 V, max forward current 300 mA, reverse recovery time 4 ns. Used here in an anti-parallel pair across the TL072 op-amp feedback loop (D1: anode→OPAMP_OUT, cathode→OPAMP_IN_NEG; D2: anode→OPAMP_IN_NEG, cathode→OPAMP_OUT) to provide soft audio clipping.

1

1N4148 Signal Diode

Fast small-signal switching diode in DO-35 glass package. Forward voltage ~0.6–0.7 V at low current, reverse breakdown 75 V, max forward current 300 mA, reverse recovery time 4 ns. Used here in an anti-parallel pair across the TL072 op-amp feedback loop (D1: anode→OPAMP_OUT, cathode→OPAMP_IN_NEG; D2: anode→OPAMP_IN_NEG, cathode→OPAMP_OUT) to provide soft audio clipping.

1

1N4148 Signal Diode

Fast small-signal switching diode in DO-35 glass package. Forward voltage ~0.6–0.7 V at low current, reverse breakdown 75 V, max forward current 300 mA, reverse recovery time 4 ns. Used here in an anti-parallel pair across the TL072 op-amp feedback loop (D1: anode→OPAMP_OUT, cathode→OPAMP_IN_NEG; D2: anode→OPAMP_IN_NEG, cathode→OPAMP_OUT) to provide soft audio clipping.

1

1N4148 Signal Diode

Fast small-signal switching diode in DO-35 glass package. Forward voltage ~0.6–0.7 V at low current, reverse breakdown 75 V, max forward current 300 mA, reverse recovery time 4 ns. Used here in an anti-parallel pair across the TL072 op-amp feedback loop (D1: anode→OPAMP_OUT, cathode→OPAMP_IN_NEG; D2: anode→OPAMP_IN_NEG, cathode→OPAMP_OUT) to provide soft audio clipping.

1

1N4148 Signal Diode

Fast small-signal switching diode in DO-35 glass package. Forward voltage ~0.6–0.7 V at low current, reverse breakdown 75 V, max forward current 300 mA, reverse recovery time 4 ns. Used here in an anti-parallel pair across the TL072 op-amp feedback loop (D1: anode→OPAMP_OUT, cathode→OPAMP_IN_NEG; D2: anode→OPAMP_IN_NEG, cathode→OPAMP_OUT) to provide soft audio clipping.

1

1N4148 Signal Diode

Fast small-signal switching diode in DO-35 glass package. Forward voltage ~0.6–0.7 V at low current, reverse breakdown 75 V, max forward current 300 mA, reverse recovery time 4 ns. Used here in an anti-parallel pair across the TL072 op-amp feedback loop (D1: anode→OPAMP_OUT, cathode→OPAMP_IN_NEG; D2: anode→OPAMP_IN_NEG, cathode→OPAMP_OUT) to provide soft audio clipping.

Assemble it in 7 steps

1. Place the board and keep USB pins free

Mount the ESP32-S3-DevKitC-1 where its native USB connector remains reachable. Do not connect anything to GPIO19 or GPIO20 because those two pins carry the USB keyboard connection to the computer.

  • GPIO19 and GPIO20 are intentionally absent from the rest of this wiring.
  • Do not feed battery power into the USB data pins; it can damage the board or computer.

2. Build the three switch rows

Arrange the nine keyboard switches in three rows of three. Join one terminal of the three top-row switches together and take that row wire to GPIO1; join the middle row and take it to GPIO2; join the bottom row and take it to GPIO3. These are the row-scan wires.

  • A mechanical switch has two electrically joined terminals on each side; use one terminal from each side, not two terminals on the same side.

3. Add one diode to every key

Connect the unused terminal of each switch to the striped end (cathode) of its own 1N4148 diode. Join the unstriped diode ends from column 1 to GPIO4, column 2 to GPIO5, and column 3 to GPIO6. The stripe must face the switch/row side.

  • The black or silver band on each diode identifies the striped cathode end.
  • A diode installed backwards can make its key fail or cause several keys to appear pressed together.

4. Wire the screen and layer knob

Connect the OLED VCC → 3V3 (power), GND → GND (ground), SDA → GPIO8 (data), and SCL → GPIO9 (clock). Connect the layer encoder VCC → 3V3 (power), GND → GND (ground), CLK → GPIO10 (turn signal), DT → GPIO11 (turn signal), and SW → GPIO12 (push signal).

  • Make sure OLED VCC and GND are not swapped — swapped power can damage the screen.

5. Wire the menu knob

Connect the menu encoder VCC → 3V3 (power), GND → GND (ground), CLK → GPIO13 (turn signal), DT → GPIO14 (turn signal), and SW → GPIO15 (push signal).

  • If a knob scrolls backwards, swap only its CLK and DT wires.

6. Build the protected battery supply

Connect LiPo +V → TP4056 B+ (battery positive) and LiPo GND → TP4056 B- (battery negative). Connect TP4056 OUT+ → boost VIN+ (power) and OUT- → boost VIN- (ground). Set the boost converter output to exactly 5.0 V with a multimeter before connecting it, then connect VOUT+ → ESP32 VIN/5V (power) and VOUT- → ESP32 GND (ground). Connect a separate 5 V USB charger to TP4056 IN+ and IN- for charging.

  • Use a TP4056 module with B+/B-/OUT+/OUT- pads so its protection circuit is in the battery-to-load path.
  • Never connect the LiPo directly to the ESP32 5 V/VIN pin — a single LiPo cell is not a regulated 5 V supply.
  • Do not leave the boost converter at an unknown voltage; output above 5 V can damage the ESP32.

7. Make the first USB connection

After checking every power and ground wire, plug the ESP32-S3 native USB connector into the computer. This cable is the HID keyboard connection and is also used by Schematik to deploy the firmware.

  • Do not use a charge-only USB cable; the computer needs the data wires for keyboard emulation.

Review all connections

1. Connections between "oled_1" and "ESP32"

Functionoled_1ESP32
powerVCC3V3
groundGNDGND
i2cSDAGPIO 8
i2cSCLGPIO 9

2. Connections between "encoder_layer" and "ESP32"

Functionencoder_layerESP32
powerVCC3V3
groundGNDGND
digitalCLKGPIO 10
digitalDTGPIO 11
digitalSWGPIO 12

3. Connections between "encoder_menu" and "ESP32"

Functionencoder_menuESP32
powerVCC3V3
groundGNDGND
digitalCLKGPIO 13
digitalDTGPIO 14
digitalSWGPIO 15

4. Connections between "lipo_1" and "ESP32"

Functionlipo_1ESP32
power+V → TP4056 Li-Ion/LiPo charger module with protection B+EXT
groundGND → TP4056 Li-Ion/LiPo charger module with protection B-EXT

5. Connections between "charger_1" and "ESP32"

Functioncharger_1ESP32
powerIN+ → 5 V USB charging inputEXT
groundIN- → USB charging input groundEXT
powerOUT+ → Boost Converter VIN+EXT
groundOUT- → Boost Converter VIN-EXT

6. Connections between "boost_5v" and "ESP32"

Functionboost_5vESP32
powerVOUT+VIN
groundVOUT-GND

7. Connections between "key_r1c1" and "ESP32"

Functionkey_r1c1ESP32
dataAGPIO 1
dataB → 1N4148 Signal Diode CATHODEEXT

8. Connections between "diode_r1c1" and "ESP32"

Functiondiode_r1c1ESP32
dataANODEGPIO 4

9. Connections between "key_r1c2" and "ESP32"

Functionkey_r1c2ESP32
dataB → 1N4148 Signal Diode CATHODEEXT
dataA → Mechanical keyboard switch (R1C1) AEXT

10. Connections between "diode_r1c2" and "ESP32"

Functiondiode_r1c2ESP32
dataANODEGPIO 5

11. Connections between "key_r1c3" and "ESP32"

Functionkey_r1c3ESP32
dataB → 1N4148 Signal Diode CATHODEEXT
dataA → Mechanical keyboard switch (R1C1) AEXT

12. Connections between "diode_r1c3" and "ESP32"

Functiondiode_r1c3ESP32
dataANODEGPIO 6

13. Connections between "key_r2c1" and "ESP32"

Functionkey_r2c1ESP32
dataAGPIO 2
dataB → 1N4148 Signal Diode CATHODEEXT

14. Connections between "key_r2c2" and "ESP32"

Functionkey_r2c2ESP32
dataB → 1N4148 Signal Diode CATHODEEXT
dataA → Mechanical keyboard switch (R2C1) AEXT

15. Connections between "key_r2c3" and "ESP32"

Functionkey_r2c3ESP32
dataB → 1N4148 Signal Diode CATHODEEXT
dataA → Mechanical keyboard switch (R2C1) AEXT

16. Connections between "key_r3c1" and "ESP32"

Functionkey_r3c1ESP32
dataAGPIO 3
dataB → 1N4148 Signal Diode CATHODEEXT

17. Connections between "key_r3c2" and "ESP32"

Functionkey_r3c2ESP32
dataB → 1N4148 Signal Diode CATHODEEXT
dataA → Mechanical keyboard switch (R3C1) AEXT

18. Connections between "key_r3c3" and "ESP32"

Functionkey_r3c3ESP32
dataB → 1N4148 Signal Diode CATHODEEXT
dataA → Mechanical keyboard switch (R3C1) AEXT

19. Connections between "diode_r2c1" and "ESP32"

Functiondiode_r2c1ESP32
dataANODE → 1N4148 Signal Diode ANODEEXT

20. Connections between "diode_r3c1" and "ESP32"

Functiondiode_r3c1ESP32
dataANODE → 1N4148 Signal Diode ANODEEXT

21. Connections between "diode_r2c2" and "ESP32"

Functiondiode_r2c2ESP32
dataANODE → 1N4148 Signal Diode ANODEEXT

22. Connections between "diode_r3c2" and "ESP32"

Functiondiode_r3c2ESP32
dataANODE → 1N4148 Signal Diode ANODEEXT

23. Connections between "diode_r2c3" and "ESP32"

Functiondiode_r2c3ESP32
dataANODE → 1N4148 Signal Diode ANODEEXT

24. Connections between "diode_r3c3" and "ESP32"

Functiondiode_r3c3ESP32
dataANODE → 1N4148 Signal Diode ANODEEXT

Deploy the firmware

#include <Arduino.h>
#include <Wire.h>
#include <WiFi.h>
#include <time.h>
#include <USB.h>
#include <USBHIDKeyboard.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>

// Set these before deployment to enable the automatic Wi-Fi/NTP clock sync.
// The macro pad still works as a USB keyboard without Wi-Fi after the boot attempt.
static const char *WIFI_SSID = "";
static const char *WIFI_PASSWORD = "";
static const char *TZ_INFO = "UTC0";  // Change to a POSIX TZ string for local time.

constexpr int ROW1_PIN = 1;
constexpr int ROW2_PIN = 2;
constexpr int ROW3_PIN = 3;
constexpr int COL1_PIN = 4;
constexpr int COL2_PIN = 5;
constexpr int COL3_PIN = 6;
constexpr int OLED_SDA_PIN = 8;
constexpr int OLED_SCL_PIN = 9;
constexpr int LAYER_CLK_PIN = 10;
constexpr int LAYER_DT_PIN = 11;
constexpr int LAYER_SW_PIN = 12;
constexpr int MENU_CLK_PIN = 13;
constexpr int MENU_DT_PIN = 14;
constexpr int MENU_SW_PIN = 15;

constexpr uint8_t SCREEN_WIDTH = 128;
constexpr uint8_t SCREEN_HEIGHT = 64;
constexpr uint8_t OLED_ADDRESS = 0x3C;
constexpr uint8_t LAYER_COUNT = 3;
constexpr uint8_t KEY_COUNT = 9;
constexpr uint16_t KEY_DEBOUNCE_MS = 18;
constexpr uint16_t ENCODER_BUTTON_DEBOUNCE_MS = 30;
constexpr uint16_t MATRIX_SCAN_MS = 5;

const uint8_t rowPins[3] = {ROW1_PIN, ROW2_PIN, ROW3_PIN};
const uint8_t colPins[3] = {COL1_PIN, COL2_PIN, COL3_PIN};
const char *menuItems[] = {"Macro mode", "Key labels", "Clock"};
constexpr uint8_t MENU_ITEM_COUNT = sizeof(menuItems) / sizeof(menuItems[0]);

Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, -1);
USBHIDKeyboard Keyboard;

struct EncoderState {
  uint8_t clkPin;
  uint8_t dtPin;
  uint8_t swPin;
  uint8_t lastAB;
  int8_t accumulator;
  bool lastSwitchReading;
  bool stablePressed;
  uint32_t switchChangedAt;
};

EncoderState layerEncoder = {LAYER_CLK_PIN, LAYER_DT_PIN, LAYER_SW_PIN, 0, 0, false, false, 0};
EncoderState menuEncoder = {MENU_CLK_PIN, MENU_DT_PIN, MENU_SW_PIN, 0, 0, false, false, 0};

bool rawKeys[3][3] = {};
bool stableKeys[3][3] = {};
uint32_t keyChangedAt[3][3] = {};
uint8_t activeLayer = 0;
uint8_t menuIndex = 0;
String statusLine = "Ready";
bool uiDirty = true;
time_t shownSecond = -1;
uint32_t lastMatrixScan = 0;

void sendKeyWithCtrl(uint8_t key) {
  Keyboard.press(KEY_LEFT_CTRL);
  Keyboard.press(key);
  delay(8);
  Keyboard.releaseAll();
}

void runMacro(uint8_t layer, uint8_t key) {
  // Edit this switch to assign your own macro sequences for all 27 layer/key slots.
  if (layer == 0) {
    switch (key) {
      case 0: sendKeyWithCtrl('c'); break;
      case 1: sendKeyWithCtrl('v'); break;
      case 2: sendKeyWithCtrl('z'); break;
      case 3: sendKeyWithCtrl('x'); break;
      case 4: sendKeyWithCtrl('s'); break;
      case 5: Keyboard.write(KEY_RETURN); break;
      case 6: Keyboard.write(KEY_LEFT_ARROW); break;
      case 7: Keyboard.write(KEY_UP_ARROW); break;
      case 8: Keyboard.write(KEY_RIGHT_ARROW); break;
    }
  } else if (layer == 1) {
    const uint8_t functionKeys[KEY_COUNT] = {KEY_F1, KEY_F2, KEY_F3, KEY_F4, KEY_F5, KEY_F6, KEY_F7, KEY_F8, KEY_F9};
    Keyboard.write(functionKeys[key]);
  } else {
    const char *textMacros[KEY_COUNT] = {
      "Hello!", "Thank you!", "On my way.",
      "// TODO", "[DONE]", "https://",
      "user@example.com", "Meeting notes: ", "Regards,"
    };
    Keyboard.print(textMacros[key]);
  }
}

String keyCoordinate(uint8_t index) {
  return "R" + String(index / 3 + 1) + "C" + String(index % 3 + 1);
}

void handleKeyEvent(uint8_t row, uint8_t col, bool pressed) {
  const uint8_t index = row * 3 + col;
  if (pressed) {
    statusLine = keyCoordinate(index) + " pressed";
    runMacro(activeLayer, index);
  } else {
    bool anotherKeyHeld = false;
    for (uint8_t r = 0; r < 3; ++r) {
      for (uint8_t c = 0; c < 3; ++c) {
        if (stableKeys[r][c]) {
          statusLine = keyCoordinate(r * 3 + c) + " pressed";
          anotherKeyHeld = true;
          break;
        }
      }
      if (anotherKeyHeld) break;
    }
    if (!anotherKeyHeld) statusLine = "Ready";
  }
  uiDirty = true;
}

void scanMatrix() {
  const uint32_t now = millis();
  if (now - lastMatrixScan < MATRIX_SCAN_MS) return;
  lastMatrixScan = now;

  for (uint8_t r = 0; r < 3; ++r) {
    pinMode(rowPins[r], OUTPUT);
    digitalWrite(rowPins[r], LOW);
    delayMicroseconds(3);
    for (uint8_t c = 0; c < 3; ++c) {
      const bool pressed = digitalRead(colPins[c]) == LOW;
      if (pressed != rawKeys[r][c]) {
        rawKeys[r][c] = pressed;
        keyChangedAt[r][c] = now;
      }
      if (rawKeys[r][c] != stableKeys[r][c] && now - keyChangedAt[r][c] >= KEY_DEBOUNCE_MS) {
        stableKeys[r][c] = rawKeys[r][c];
        handleKeyEvent(r, c, stableKeys[r][c]);
      }
    }
    pinMode(rowPins[r], INPUT);
  }
}

int8_t pollEncoder(EncoderState &encoder) {
  const uint8_t ab = (digitalRead(encoder.clkPin) << 1) | digitalRead(encoder.dtPin);
  const uint8_t transition = (encoder.lastAB << 2) | ab;
  encoder.lastAB = ab;
  switch (transition) {
    case 0b0001: case 0b0111: case 0b1110: case 0b1000: encoder.accumulator++; break;
    case 0b0010: case 0b1011: case 0b1101: case 0b0100: encoder.accumulator--; break;
    default: break;
  }
  if (encoder.accumulator >= 4) { encoder.accumulator = 0; return 1; }
  if (encoder.accumulator <= -4) { encoder.accumulator = 0; return -1; }
  return 0;
}

bool encoderPressed(EncoderState &encoder) {
  const bool pressedNow = digitalRead(encoder.swPin) == LOW;
  const uint32_t now = millis();
  if (pressedNow != encoder.lastSwitchReading) {
    encoder.lastSwitchReading = pressedNow;
    encoder.switchChangedAt = now;
  }
  if (pressedNow != encoder.stablePressed && now - encoder.switchChangedAt >= ENCODER_BUTTON_DEBOUNCE_MS) {
    encoder.stablePressed = pressedNow;
    return pressedNow;
  }
  return false;
}

void drawUi() {
  struct tm timeInfo;
  char dateText[16] = "No time";
  char timeText[12] = "--:--:--";
  const time_t now = time(nullptr);
  if (getLocalTime(&timeInfo, 10)) {
    strftime(dateText, sizeof(dateText), "%Y-%m-%d", &timeInfo);
    strftime(timeText, sizeof(timeText), "%H:%M:%S", &timeInfo);
  }

  display.clearDisplay();
  display.setTextColor(SSD1306_WHITE);
  display.setTextSize(1);
  display.setCursor(0, 0);
  display.print(dateText);
  display.setCursor(76, 0);
  display.print(timeText);
  display.drawFastHLine(0, 10, SCREEN_WIDTH, SSD1306_WHITE);

  display.setTextSize(2);
  display.setCursor(8, 17);
  display.print("Layer ");
  display.print(activeLayer + 1);

  display.setTextSize(1);
  display.setCursor(0, 38);
  display.print("Menu: ");
  display.print(menuItems[menuIndex]);
  display.drawRect(0, 49, SCREEN_WIDTH, 15, SSD1306_WHITE);
  display.setCursor(4, 53);
  display.print(statusLine);
  display.display();
  uiDirty = false;
  shownSecond = now;
}

void syncClockAtBoot() {
  if (strlen(WIFI_SSID) == 0) {
    statusLine = "Clock: set Wi-Fi";
    return;
  }
  statusLine = "Wi-Fi clock sync";
  uiDirty = true;
  WiFi.mode(WIFI_STA);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  const uint32_t started = millis();
  while (WiFi.status() != WL_CONNECTED && millis() - started < 12000) {
    delay(100);
  }
  if (WiFi.status() == WL_CONNECTED) {
    configTzTime(TZ_INFO, "pool.ntp.org", "time.nist.gov");
    struct tm timeInfo;
    if (getLocalTime(&timeInfo, 8000)) statusLine = "Clock synchronized";
    else statusLine = "NTP unavailable";
    WiFi.disconnect(true);
    WiFi.mode(WIFI_OFF);
  } else {
    statusLine = "Wi-Fi unavailable";
  }
  uiDirty = true;
}

void setup() {
  for (uint8_t c = 0; c < 3; ++c) pinMode(colPins[c], INPUT_PULLUP);
  for (uint8_t r = 0; r < 3; ++r) pinMode(rowPins[r], INPUT);

  for (EncoderState *encoder : {&layerEncoder, &menuEncoder}) {
    pinMode(encoder->clkPin, INPUT_PULLUP);
    pinMode(encoder->dtPin, INPUT_PULLUP);
    pinMode(encoder->swPin, INPUT_PULLUP);
    encoder->lastAB = (digitalRead(encoder->clkPin) << 1) | digitalRead(encoder->dtPin);
    encoder->lastSwitchReading = digitalRead(encoder->swPin) == LOW;
  }

  Wire.begin(OLED_SDA_PIN, OLED_SCL_PIN);
  display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDRESS);
  display.clearDisplay();
  display.display();

  Keyboard.begin();
  USB.begin();
  syncClockAtBoot();
  uiDirty = true;
}

void loop() {
  scanMatrix();

  const int8_t layerStep = pollEncoder(layerEncoder);
  if (layerStep != 0) {
    activeLayer = (activeLayer + LAYER_COUNT + layerStep) % LAYER_COUNT;
    statusLine = "Layer " + String(activeLayer + 1) + " active";
    uiDirty = true;
  }
  if (encoderPressed(layerEncoder)) {
    activeLayer = 0;
    statusLine = "Layer reset";
    uiDirty = true;
  }

  const int8_t menuStep = pollEncoder(menuEncoder);
  if (menuStep != 0) {
    menuIndex = (menuIndex + MENU_ITEM_COUNT + menuStep) % MENU_ITEM_COUNT;
    statusLine = String("Select: ") + menuItems[menuIndex];
    uiDirty = true;
  }
  if (encoderPressed(menuEncoder)) {
    statusLine = String("Selected: ") + menuItems[menuIndex];
    uiDirty = true;
  }

  const time_t now = time(nullptr);
  if (now != shownSecond) uiDirty = true;
  if (uiDirty) drawUi();
  delay(1);
}

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik