Community project

Rotary Window Switching

ESP32
Photo of Rotary Window Switching
Generated with AI

Ethan Loukas

Published October 21, 2026

This project transforms a KY-040 rotary encoder into a wireless window switcher using an ESP32 and Bluetooth. Rotating the encoder sends Alt+Tab commands to switch between open windows on a paired computer, while pressing the encoder button triggers Alt+F4 to close the active window. The guide includes a wiring diagram showing how to connect the encoder's CLK, DT, and SW pins to the ESP32, a complete parts list, and firmware that handles quadrature decoding and Bluetooth HID keyboard emulation.

Builders will learn how to set up Bluetooth Low Energy communication on the ESP32, implement debouncing for reliable button detection, and decode rotary encoder signals using a quadrature lookup table. The assembly process takes just a few minutes and requires only basic soldering skills. Once programmed and paired with a computer, the rotary encoder becomes a convenient one-handed control for window management.

Wiring diagram

Gather all the parts

QtyComponent
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.

Assemble it in 5 steps

1. Unplug the ESP32 before wiring

Unplug the ESP32 from USB, then place the ESP32 CP2102 board and the KY-040 encoder module where you can read the labels on both boards. This prevents an accidental short while you add jumper wires.

  • Use five female-to-female jumper wires if both boards have header pins.
  • Do not connect or move wires while USB power is attached; a misplaced wire can short the board.

2. Connect power and ground

Connect the KY-040 VCC pin to the ESP32 3V3 pin (power). Connect the KY-040 GND pin to an ESP32 GND pin (ground).

  • Use a red wire for 3V3 and a black wire for GND so the two important connections are easy to recheck.
  • Make sure VCC goes to 3V3, not 5V — this keeps the encoder signals at a safe voltage for the ESP32. Swapped VCC and GND can damage the module.

3. Connect the turning signals

Connect KY-040 CLK to ESP32 GPIO18 (first turning signal). Connect KY-040 DT to ESP32 GPIO19 (second turning signal). These two wires tell the ESP32 which way the knob moved.

  • The CLK and DT labels are printed next to the encoder pins. If the knob later moves through apps in the direction you do not prefer, swap only these two wires.
  • Keep these wires away from the 3V3 and GND pins; putting a signal wire on a power pin can stop the knob from working.

4. Connect the push switch

Connect KY-040 SW to ESP32 GPIO21 (switch signal). The firmware reserves this built-in button for a later feature; turning the knob is what changes applications now.

  • The SW pin is separate from CLK and DT, even though all three are on the same small module.
  • Do not connect the SW pin to 3V3 or GND directly; pressing the encoder would then create a short.

5. Inspect before powering it

Check all five connections one more time: VCC to 3V3 (power), GND to GND (ground), CLK to GPIO18 (first turning signal), DT to GPIO19 (second turning signal), and SW to GPIO21 (button signal). Then plug the ESP32 into your Windows PC using its USB cable.

  • The USB cable powers the board and is used to put the firmware on it.
  • Some USB cables only charge and do not carry data. If Deploy cannot find the board later, try another known data-capable cable.

Review all connections

1. Connections between "rotary_encoder_1" and "ESP32"

Functionrotary_encoder_1ESP32
powerVCC3V3
groundGNDGND
digitalCLKGPIO 18
digitalDTGPIO 19
digitalSWGPIO 21

Deploy the firmware

#include <Arduino.h>
#include <BLEDevice.h>
#include <BLEHIDDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>

constexpr int ENCODER_CLK_PIN = 18;
constexpr int ENCODER_DT_PIN = 19;
constexpr int ENCODER_SW_PIN = 21;

BLEHIDDevice *hidDevice = nullptr;
BLECharacteristic *keyboardInput = nullptr;
volatile bool bluetoothConnected = false;
uint8_t previousEncoderState = 0;
int8_t encoderSteps = 0;
bool buttonStableState = HIGH;
bool buttonLastReading = HIGH;
unsigned long buttonLastChangeMs = 0;

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

const uint8_t KEYBOARD_REPORT_MAP[] = {
  0x05, 0x01, 0x09, 0x06, 0xA1, 0x01, 0x85, 0x01,
  0x05, 0x07, 0x19, 0xE0, 0x29, 0xE7, 0x15, 0x00,
  0x25, 0x01, 0x75, 0x01, 0x95, 0x08, 0x81, 0x02,
  0x95, 0x01, 0x75, 0x08, 0x81, 0x01, 0x95, 0x06,
  0x75, 0x08, 0x15, 0x00, 0x25, 0x65, 0x05, 0x07,
  0x19, 0x00, 0x29, 0x65, 0x81, 0x00, 0xC0
};

class KeyboardConnectionCallbacks : public BLEServerCallbacks {
  void onConnect(BLEServer *) override { bluetoothConnected = true; }
  void onDisconnect(BLEServer *server) override {
    bluetoothConnected = false;
    server->startAdvertising();
  }
};

void sendKeyboardReport(uint8_t modifier, uint8_t key) {
  if (!bluetoothConnected || keyboardInput == nullptr) return;
  uint8_t report[8] = {modifier, 0, key, 0, 0, 0, 0, 0};
  keyboardInput->setValue(report, sizeof(report));
  keyboardInput->notify();
}

void sendKey(uint8_t modifier, uint8_t key) {
  sendKeyboardReport(modifier, key);
  delay(12);
  sendKeyboardReport(0, 0);
}

constexpr uint8_t MODIFIER_LEFT_CTRL = 0x01;
constexpr uint8_t MODIFIER_LEFT_SHIFT = 0x02;
constexpr uint8_t MODIFIER_LEFT_ALT = 0x04;
constexpr uint8_t MODIFIER_LEFT_GUI = 0x08;
constexpr uint8_t KEY_ESCAPE = 0x29;
constexpr uint8_t KEY_TAB = 0x2B;

void switchApplication(bool forward) {
  // Alt+Esc switches immediately through recently used application windows.
  // Shift reverses the direction without leaving the app-switcher on screen.
  const uint8_t modifiers = forward
      ? MODIFIER_LEFT_ALT
      : (MODIFIER_LEFT_ALT | MODIFIER_LEFT_SHIFT);
  sendKey(modifiers, KEY_ESCAPE);
}

void switchToMostRecentApplication() {
  // A normal Alt+Tab press immediately returns to the most recently used app.
  // Repeating it alternates between the two most recently used applications.
  sendKey(MODIFIER_LEFT_ALT, KEY_TAB);
}

void readButton() {
  const bool reading = digitalRead(ENCODER_SW_PIN);
  if (reading != buttonLastReading) {
    buttonLastChangeMs = millis();
    buttonLastReading = reading;
  }

  // A press is counted once after the switch contacts have settled.
  if ((millis() - buttonLastChangeMs) >= 35 && reading != buttonStableState) {
    buttonStableState = reading;
    if (buttonStableState == LOW) {
      switchToMostRecentApplication();
    }
  }
}

void readEncoder() {
  const uint8_t currentState =
      (static_cast<uint8_t>(digitalRead(ENCODER_CLK_PIN)) << 1) |
      static_cast<uint8_t>(digitalRead(ENCODER_DT_PIN));
  const int8_t movement = QUADRATURE_TABLE[(previousEncoderState << 2) | currentState];
  previousEncoderState = currentState;
  if (movement == 0) return;
  encoderSteps += movement;
  if (encoderSteps >= 4) {
    encoderSteps = 0;
    switchApplication(true);
  } else if (encoderSteps <= -4) {
    encoderSteps = 0;
    switchApplication(false);
  }
}

void setup() {
  pinMode(ENCODER_CLK_PIN, INPUT_PULLUP);
  pinMode(ENCODER_DT_PIN, INPUT_PULLUP);
  pinMode(ENCODER_SW_PIN, INPUT_PULLUP);
  previousEncoderState =
      (static_cast<uint8_t>(digitalRead(ENCODER_CLK_PIN)) << 1) |
      static_cast<uint8_t>(digitalRead(ENCODER_DT_PIN));
  buttonStableState = digitalRead(ENCODER_SW_PIN);
  buttonLastReading = buttonStableState;

  BLEDevice::init("ESP32 App Knob");
  BLEServer *server = BLEDevice::createServer();
  server->setCallbacks(new KeyboardConnectionCallbacks());
  hidDevice = new BLEHIDDevice(server);
  keyboardInput = hidDevice->inputReport(1);
  hidDevice->manufacturer()->setValue("Schematik");
  hidDevice->pnp(0x02, 0xE502, 0xA111, 0x0210);
  hidDevice->hidInfo(0x00, 0x01);
  hidDevice->reportMap(const_cast<uint8_t *>(KEYBOARD_REPORT_MAP), sizeof(KEYBOARD_REPORT_MAP));
  hidDevice->startServices();
  hidDevice->setBatteryLevel(100);

  BLEAdvertising *advertising = server->getAdvertising();
  advertising->setAppearance(0x03C1);
  advertising->addServiceUUID(hidDevice->hidService()->getUUID());
  advertising->start();
}

void loop() {
  readEncoder();
  readButton();
  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