This media control relay panel uses an ESP32 microcontroller to create a customizable control hub with dual displays, motion sensing, and wireless relay communication. The panel features a 20x4 LCD and 128x32 OLED display for status feedback, a rotary encoder and four buttons for user input, addressable NeoPixel LEDs for visual feedback, and an MPU-6050 motion sensor for gesture-based control.
This guide provides a complete wiring diagram, detailed parts list, and step-by-step assembly instructions for building the control panel from scratch. The included firmware supports multiple operation modes including media shortcuts for Windows and Mac, lighting effects control, and motion-based mouse input. Proper level shifting and power management ensure reliable communication between the 3.3V ESP32 and 5V peripherals.
Wiring diagram
Gather all the parts
Assemble it in 5 steps
1. Make the shared ground and display wires
With the ESP32 unplugged, join the GND pins of the ESP32, both displays, encoder, four buttons, LED module, level shifter, and capacitor. Connect OLED VCC to 3V3, SDA to GPIO21 and SCL to GPIO22. Power the LCD from VIN/5V. Connect the BSS138 level shifter LV to 3V3, HV to VIN/5V and GND to GND. Connect LV1 (shown as SDA) to GPIO21 and LV2 (shown as SCL) to GPIO22; connect HV1 (SDA_HV) to LCD SDA and HV2 (SCL_HV) to LCD SCL. The MPU6050 stays on the 3.3V side.
- The LCD and OLED can share the same two data wires because they use different screen addresses.
- Make sure VCC and GND are not swapped on either screen — swapped power can damage a display. If your LCD backpack only works at 5 V or pulls its SDA/SCL pins up to 5 V, use an I2C level shifter instead of connecting it directly.
2. Wire the knob and four buttons
Connect encoder VCC to 3V3 (power), encoder GND to GND (ground), encoder CLK to GPIO32 (rotation signal), encoder DT to GPIO33 (rotation signal), and encoder SW to GPIO25 (push signal). For each push button, connect one side to GND and the other side to its signal pin: button 1 to GPIO26, button 2 to GPIO27, button 3 to GPIO16, and button 4 to GPIO17.
- The buttons use the ESP32's internal pull-up resistors, so no separate button resistors are needed.
- A four-legged tactile button has the two legs on each same side already connected; use legs from opposite sides.
3. Build the protected LED data connection
Power the four-pixel LED module from the ESP32 VIN/5V pin (power) and GND (ground). Put the 1000 µF capacitor across these same LED power wires: capacitor + to VIN/5V and the striped capacitor - lead to GND. Power the 74AHCT125 level shifter from VIN/5V and GND. Connect its 1A input to GPIO13 (signal), connect 1OE to GND so that output is enabled, then connect 1Y to one end of the 330 Ω resistor and the resistor's other end to the LED module DIN pin.
- Keep the resistor near the LED module and keep the LED power wires short.
- The level shifter changes the ESP32's 3.3 V signal into a reliable 5 V LED signal.
- The electrolytic capacitor is polarized: its striped negative lead must go to GND. Reversing it can damage the capacitor.
- Do not connect the LED DIN wire directly to VIN/5V; it belongs on the level-shifter output through the resistor.
4. Check before powering it
Check that every module has a ground wire in common with the ESP32. Confirm the two display data wires go only to GPIO21 and GPIO22, and confirm no button signal is connected to 3V3 or 5V. Finally connect the ESP32 to a USB power source; this powers VIN/5V for the LED module and level shifter.
- The relay box is wireless; it has no signal wire to this controller. The firmware talks to its configured MAC address over ESP-NOW.
- Disconnect USB immediately if a part becomes hot or you smell hot plastic — that usually means a power wire is in the wrong place.
5. Add the motion sensor
With USB unplugged, connect MPU6050 VIN (VCC on GY-521 modules) to 3V3, GND to GND, SDA to GPIO21 (data), and SCL to GPIO22 (clock). Leave INT unused. On a GY-521 leave AD0 low for address 0x68. Mount the sensor firmly with its Z axis pointing upward and X axis toward the front of the controller. Hold it still while powering up for automatic gyro calibration.
- Press the encoder to cycle to Mouse Mode. Button 1 right-clicks, Button 4 left-clicks, Button 3 toggles scrolling. Turn the encoder to scroll while scrolling is ON; otherwise it adjusts volume. Button 2 recalibrates while you hold still.
- Remove the old SajanMediaCtrl pairing in Windows and pair again after deploying, because the Bluetooth device now also includes a mouse.
- Use 3.3V sensor power to keep the shared data wires safe. Do not power this sensor from 5V on this shared bus.
Review all connections
1. Connections between "lcd_20x4" and "ESP32"
2. Connections between "oled_128x32" and "ESP32"
3. Connections between "encoder" and "ESP32"
4. Connections between "button_1" and "ESP32"
5. Connections between "button_2" and "ESP32"
6. Connections between "button_3" and "ESP32"
7. Connections between "button_4" and "ESP32"
8. Connections between "neopixel_4" and "ESP32"
9. Connections between "neopixel_capacitor" and "ESP32"
10. Connections between "neopixel_level_shifter" and "ESP32"
11. Connections between "mouse_imu" and "ESP32"
12. Connections between "lcd_level_shifter" and "ESP32"
Deploy the firmware
#include <Arduino.h>
#include "MotionMouse.h"
#include <Adafruit_MPU6050.h>
#include <EEPROM.h>
#include <Adafruit_NeoPixel.h>
#include <ESP32Encoder.h>
#include <Wire.h>
#include <LiquidCrystal_I2C.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <esp_now.h>
#include <esp_idf_version.h>
#include <WiFi.h>
#include <esp_wifi.h>
// Sajan Media Ctrl v2.1 - ESP32 Arduino Core 3.x compatible
#define LED_PIN 13
#define LED_COUNT 4
#define ENC_CLK 32
#define ENC_DT 33
#define ENC_SW 25
#define BTN1 26
#define BTN2 27
#define BTN3 16
#define BTN4 17
#define I2C_SDA 21
#define I2C_SCL 22
#define OLED_ADDR 0x3C
#define EEPROM_SIZE 512
#define MAGIC_ADDR 20
#define MAGIC 0xA5
#define MODE_LIGHTS 7
#define MODE_SETTINGS 4
#define MODE_MOUSE 8
ControllerHID keyboard("SajanMediaCtrl", "Sajan", 100);
Adafruit_NeoPixel pixels(LED_COUNT, LED_PIN, NEO_GRB + NEO_KHZ800);
ESP32Encoder encoder;
Adafruit_SSD1306 oled(128, 32, &Wire, -1);
LiquidCrystal_I2C *lcd = nullptr;
uint8_t relayMAC[] = {0xE8, 0xF6, 0x0A, 0x8B, 0x4C, 0xC4};
volatile bool newReply = false;
volatile bool sendOK = false;
char reply[9] = {0};
bool peerAdded = false, relayOnline = false, bleOnline = false, oledOK = false;
bool relayState[6] = {false, false, false, false, false, false};
int mode = 0, volume = 50, lastVolume = 50, brightness = 100, effect = 0, speed = 50, setting = 0, lightPage = 0;
// 0 = Windows shortcuts, 1 = Mac shortcuts. Saved in EEPROM.
int computerMode = 0;
bool settingsEditing = false;
bool lcdBacklight = true;
const int EFFECT_COUNT = 14;
long lastEncoder = 0;
bool lastEncoderButton = true;
unsigned long lastButton = 0, lastQuery = 0, lastRelayReply = 0, lastBLECheck = 0, effectTick = 0, blinkTick = 0;
bool blinkState = false;
String oledNoticeTitle = "", oledNoticeDetail = "";
unsigned long oledNoticeUntil = 0, oledAnimTick = 0;
unsigned long heldAt[4] = {0, 0, 0, 0};
bool holding[4] = {false, false, false, false};
Adafruit_MPU6050 mouseIMU;
bool imuOK = false, scrollLatched = false;
float gyroBiasX = 0, gyroBiasZ = 0;
unsigned long mouseTick = 0;
void calibrateMouse() {
if (!imuOK) return;
gyroBiasX = gyroBiasZ = 0;
for (int i = 0; i < 100; ++i) {
sensors_event_t a, g, t; mouseIMU.getEvent(&a, &g, &t);
gyroBiasX += g.gyro.x; gyroBiasZ += g.gyro.z; delay(5);
}
gyroBiasX /= 100; gyroBiasZ /= 100;
}
void serviceMouse() {
if (mode != MODE_MOUSE || !imuOK || !bleOnline || millis() - mouseTick < 20) return;
mouseTick = millis();
sensors_event_t a, g, t;
if (!mouseIMU.getEvent(&a, &g, &t)) return;
float x = -(g.gyro.z - gyroBiasZ), y = -(g.gyro.x - gyroBiasX);
int dx = fabsf(x) < 0.035f ? 0 : constrain(int(x * 22), -50, 50);
int dy = fabsf(y) < 0.035f ? 0 : constrain(int(y * 22), -50, 50);
if (dx || dy) keyboard.moveMouse(dx, dy);
}
int clampValue(int v, int low, int high) { return v < low ? low : (v > high ? high : v); }
void save(); void drawScreens(); void drawOLED();
void showOLEDNotice(const char *title, const char *detail, unsigned long duration = 1600) {
oledNoticeTitle = title; oledNoticeDetail = detail; oledNoticeUntil = millis() + duration;
drawOLED();
}
// IDF 5.5 changed the ESP-NOW send callback from a MAC pointer to send-info.
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 5, 0)
void onSend(const esp_now_send_info_t *info, esp_now_send_status_t status) { (void)info; sendOK = status == ESP_NOW_SEND_SUCCESS; }
#else
void onSend(const uint8_t *mac, esp_now_send_status_t status) { (void)mac; sendOK = status == ESP_NOW_SEND_SUCCESS; }
#endif
// Only accept complete office status packets from the configured receiver.
portMUX_TYPE replyMux = portMUX_INITIALIZER_UNLOCKED;
uint8_t searchChannel = 1;
unsigned long lastChannelHop = 0;
void onReceive(const esp_now_recv_info_t *info, const uint8_t *data, int len) {
if (!info || memcmp(info->src_addr, relayMAC, 6) != 0 || len != 7 || data[0] != 'S') return;
for (int i = 1; i < 7; ++i) if (data[i] != '0' && data[i] != '1') return;
portENTER_CRITICAL(&replyMux);
memcpy(reply, data, 7); reply[7] = 0; newReply = true;
portEXIT_CRITICAL(&replyMux);
}
void writeIfChanged(int address, uint8_t value) { if (EEPROM.read(address) != value) EEPROM.write(address, value); }
void resetMemory() {
for (int i = 0; i < EEPROM_SIZE; i++) EEPROM.write(i, 0);
EEPROM.write(1, 50); EEPROM.write(2, 100); EEPROM.write(3, 1); EEPROM.write(4, 0); EEPROM.write(5, 50); EEPROM.write(6, 0); EEPROM.write(MAGIC_ADDR, MAGIC); EEPROM.commit(); ESP.restart();
}
void load() {
if (EEPROM.read(MAGIC_ADDR) != MAGIC) { resetMemory(); return; }
mode = clampValue(EEPROM.read(0), 0, 8); volume = clampValue(EEPROM.read(1), 0, 100); brightness = clampValue(EEPROM.read(2), 0, 100); lcdBacklight = EEPROM.read(3) != 0; effect = clampValue(EEPROM.read(4), 0, EFFECT_COUNT - 1); speed = clampValue(EEPROM.read(5), 1, 100); computerMode = clampValue(EEPROM.read(6), 0, 1);
for (int i = 0; i < 6; i++) relayState[i] = EEPROM.read(10 + i) != 0;
lastVolume = volume;
}
void save() {
writeIfChanged(0, mode); writeIfChanged(1, volume); writeIfChanged(2, brightness); writeIfChanged(3, lcdBacklight ? 1 : 0); writeIfChanged(4, effect); writeIfChanged(5, speed); writeIfChanged(6, computerMode);
for (int i = 0; i < 6; i++) writeIfChanged(10 + i, relayState[i]);
EEPROM.write(MAGIC_ADDR, MAGIC); EEPROM.commit();
}
void sendQuery() { if (peerAdded) { uint8_t q = 'Q'; esp_now_send(relayMAC, &q, 1); } }
// The supplied office YAML toggles with BTN1..BTN6. Never retry a toggle.
void sendToggle(int channel) {
if (!peerAdded || channel < 0 || channel > 5) return;
if (!relayOnline) { showOLEDNotice("OFFICE OFFLINE", "Waiting for status"); return; }
char msg[5];
snprintf(msg, sizeof(msg), "BTN%d", channel + 1);
esp_now_send(relayMAC, reinterpret_cast<const uint8_t *>(msg), 4);
}
void acceptReply() {
char snapshot[8];
portENTER_CRITICAL(&replyMux);
memcpy(snapshot, reply, 8); newReply = false;
portEXIT_CRITICAL(&replyMux);
bool changed = !relayOnline;
for (int i = 0; i < 6; ++i) {
const bool state = snapshot[i + 1] == '1';
changed |= relayState[i] != state; relayState[i] = state;
}
relayOnline = true; lastRelayReply = millis();
if (changed) drawScreens();
}
void serviceOffice() {
if (!peerAdded) return;
if (newReply) acceptReply();
if (relayOnline && millis() - lastRelayReply > 12000) {
relayOnline = false; lastChannelHop = 0; drawScreens();
}
if (!relayOnline && millis() - lastChannelHop >= 800) {
lastChannelHop = millis();
// The driver rejects channels unavailable in its configured country.
if (esp_wifi_set_channel(searchChannel, WIFI_SECOND_CHAN_NONE) == ESP_OK) sendQuery();
searchChannel = searchChannel % 13 + 1;
} else if (relayOnline && millis() - lastQuery >= 3000) {
sendQuery(); lastQuery = millis();
}
}
void lcdRow(int row, String text) { if (!lcd) return; while (text.length() < 20) text += ' '; lcd->setCursor(0, row); lcd->print(text.substring(0, 20)); }
void drawOLED() {
if (!oledOK) return;
oled.clearDisplay(); oled.setTextColor(SSD1306_WHITE); oled.setTextSize(1);
if (millis() < oledNoticeUntil) {
oled.setCursor(0, 0); oled.print(oledNoticeTitle);
oled.drawFastHLine(0, 10, 128, SSD1306_WHITE);
oled.setCursor(0, 16);
if (oledNoticeTitle == "SYSTEM") {
const uint8_t dots = (millis() / 180) % 4;
oled.print("Working"); for (uint8_t i = 0; i < dots; i++) oled.print('.');
oled.setCursor(0, 25); oled.print(oledNoticeDetail);
} else {
oled.print(oledNoticeDetail);
}
} else if (mode == MODE_MOUSE) {
oled.setCursor(0,0); oled.print("MOUSE");
oled.setCursor(0,12); oled.print(imuOK ? (scrollLatched ? "Scroll ON" : "Scroll OFF") : "MPU6050 missing");
oled.setCursor(0,24); oled.print(bleOnline ? "B1 Right / B4 Left" : "Not connected");
} else if (mode == MODE_LIGHTS) {
oled.setCursor(0, 0); oled.print("LIGHTS"); oled.setCursor(82, 0); oled.print(relayOnline ? "CONN" : "----");
for (int i = 0; i < 6; i++) { int x = 1 + i * 21; if (relayState[i]) { oled.fillRect(x, 10, 19, 12, SSD1306_WHITE); oled.setTextColor(SSD1306_BLACK); } else oled.drawRect(x, 10, 19, 12, SSD1306_WHITE); oled.setCursor(x + 3, 12); oled.print('F'); oled.print(i + 1); oled.setTextColor(SSD1306_WHITE); }
oled.setCursor(0, 25); oled.print(lightPage ? "BTN: F5 F6" : "BTN: F1 F2 F3 F4");
} else {
oled.setCursor(0, 0); oled.print("VOLUME"); oled.setCursor(96, 0); oled.print(bleOnline ? "BLE" : "---");
oled.setTextSize(2); oled.setCursor(0, 11); oled.print(volume); oled.print('%'); oled.drawRect(70, 14, 56, 10, SSD1306_WHITE); oled.fillRect(71, 15, map(volume, 0, 100, 0, 54), 8, SSD1306_WHITE);
}
oled.display();
}
void drawScreens() {
if (!lcd) { drawOLED(); return; }
lcd->clear();
if (mode == MODE_MOUSE) {
lcdRow(0, "Mouse Mode"); lcdRow(1, bleOnline ? "Device Connected" : "Not connected");
lcdRow(2, imuOK ? (scrollLatched ? "Encoder: Scroll ON" : "Encoder: Volume") : "MPU6050 missing");
lcdRow(3, "1:R 2:Cal 3:Scr 4:L");
} else if (mode == MODE_LIGHTS) {
lcdRow(0, "Sajan Media Ctrl"); lcdRow(1, "Office Switch"); lcdRow(2, relayOnline ? "Relay: Connected" : "Searching office...");
String line; const int base = lightPage == 0 ? 0 : 4; const int count = lightPage == 0 ? 4 : 2; for (int i = 0; i < count; i++) line += "F" + String(base + i + 1) + (relayState[base + i] ? "ON " : "-- "); lcdRow(3, line);
} else if (mode == MODE_SETTINGS) {
const char *effectNames[EFFECT_COUNT] = {"Static", "Breathe", "Rainbow", "Chase", "Pulse", "Theater", "Wave", "Sparkle", "Gradient", "Alternate", "Comet", "Twinkle", "Scanner", "Fire"};
// Button 1 = up, Button 2 = down, Button 4 = select/edit. The encoder changes the selected item.
const String mark = settingsEditing ? " *" : " >";
lcdRow(0, "LED " + String(brightness) + "%" + (setting == 0 ? mark : ""));
lcdRow(1, "LCD " + String(lcdBacklight ? "ON" : "OFF") + (setting == 1 ? mark : ""));
if (setting == 2) lcdRow(2, "Effect " + String(effect + 1) + " " + effectNames[effect] + mark);
else if (setting == 3) lcdRow(2, "Effect Speed " + String(speed) + "%" + mark);
else lcdRow(2, "Effect " + String(effect + 1) + " " + effectNames[effect]);
lcdRow(3, "OS " + String(computerMode == 0 ? "WINDOWS" : "MAC") + (setting == 4 ? mark : ""));
} else {
const char *names[] = {"YouTube Mode", "Spotify Mode", "Shorts Mode", "VLC Mode", "Settings", "PPT Mode", "System ", "Lights"};
const char *help[] = {"-10. Play +10 Full", "Prev Play Next Mute", "Up Space Down Mute", "Left Play Right Full", "", "Prev Next f5 Black", "Slp Rst Sht Desk", ""};
lcdRow(0, "Sajan Media Ctrl"); lcdRow(1, names[mode]);
lcdRow(2, bleOnline ? "Device Connected" : "Not Connected");
lcdRow(3, help[mode]);
}
drawOLED();
}
void updateLEDs() {
pixels.setBrightness(brightness);
if (mode == MODE_LIGHTS) {
if (relayOnline) { pixels.fill(0x00BFFF); pixels.show(); }
else if (millis() - blinkTick > 500) { blinkTick = millis(); blinkState = !blinkState; pixels.fill(blinkState ? 0x00BFFF : 0); pixels.show(); }
return;
}
if (millis() - effectTick < (unsigned long)map(speed, 1, 100, 200, 25)) return;
effectTick = millis();
uint32_t colors[] = {0xFF0000, 0x00FF00, 0xFFFF00, 0xFFA500, 0xFFFFFF, 0xFF00FF, 0x00BFFF, 0x0040FF};
uint32_t color = mode == MODE_SETTINGS ? 0x666666 : colors[mode % 8];
if (effect == 0) pixels.fill(color);
else if (effect == 1) { uint8_t b = (sin(millis() / 500.0) + 1.0) * 127; uint8_t r = color >> 16, g = color >> 8, bl = color; for (int i = 0; i < LED_COUNT; i++) pixels.setPixelColor(i, pixels.Color(r * b / 255, g * b / 255, bl * b / 255)); }
else if (effect == 2) { for (int i = 0; i < LED_COUNT; i++) pixels.setPixelColor(i, pixels.gamma32(pixels.ColorHSV((millis() * 30 + i * 16000) & 65535))); }
else if (effect == 3) { static int pos = 0; pixels.clear(); pixels.setPixelColor(pos, color); pos = (pos + 1) % LED_COUNT; }
else if (effect == 4) { static int phase = 0; int level = abs((phase++ % 510) - 255); uint8_t r=color>>16,g=color>>8,b=color; for(int i=0;i<LED_COUNT;i++) pixels.setPixelColor(i,pixels.Color(r*level/255,g*level/255,b*level/255)); }
else if (effect == 5) { static bool phase=false; pixels.clear(); for(int i=phase?1:0;i<LED_COUNT;i+=2) pixels.setPixelColor(i,color); phase=!phase; }
else if (effect == 6) { static int phase=0; uint8_t r=color>>16,g=color>>8,b=color; for(int i=0;i<LED_COUNT;i++){ uint8_t level=uint8_t(sin(phase*0.3f+i*0.8f)*127+128); pixels.setPixelColor(i,pixels.Color(r*level/255,g*level/255,b*level/255)); } phase++; }
else if (effect == 7) { for(int i=0;i<LED_COUNT;i++) pixels.setPixelColor(i,random(100)<20?0xFFFFFF:color); }
else if (effect == 8) { static uint16_t hue=0; for(int i=0;i<LED_COUNT;i++) pixels.setPixelColor(i,pixels.gamma32(pixels.ColorHSV(hue+i*16384))); hue+=700; }
else if (effect == 9) { static bool phase=false; pixels.clear(); for(int i=phase?1:0;i<LED_COUNT;i+=2) pixels.setPixelColor(i,color); phase=!phase; }
else if (effect == 10) { static int step=0; int p=step++%(LED_COUNT*2-2); if(p>=LED_COUNT)p=LED_COUNT*2-2-p; pixels.setPixelColor(p,0xFFFFFF); if(p>0)pixels.setPixelColor(p-1,color); }
else if (effect == 11) { for(int i=0;i<LED_COUNT;i++) if(random(100)<35) pixels.setPixelColor(i,color); }
else if (effect == 12) { static int step=0; int p=step++%(LED_COUNT*2-2); if(p>=LED_COUNT)p=LED_COUNT*2-2-p; pixels.setPixelColor(p,color); }
else { for(int i=0;i<LED_COUNT;i++){ int heat=random(80,256); pixels.setPixelColor(i,pixels.Color(heat,heat/5,0)); } }
pixels.show();
}
void mediaVolume(int direction) {
if (!bleOnline) return;
if (volume == 0 || (lastVolume == 0 && volume > 0)) keyboard.write(KEY_MEDIA_MUTE); else keyboard.write(direction > 0 ? KEY_MEDIA_VOLUME_UP : KEY_MEDIA_VOLUME_DOWN);
lastVolume = volume;
}
void windowsPower(char finalKey) { keyboard.press(KEY_LEFT_GUI); keyboard.press('x'); delay(90); keyboard.releaseAll(); delay(180); keyboard.write('u'); delay(140); keyboard.write(finalKey); }
void desktop() { keyboard.press(KEY_LEFT_GUI); keyboard.press('d'); delay(60); keyboard.releaseAll(); }
void macShortcut(char key) { keyboard.press(KEY_LEFT_GUI); keyboard.press(key); delay(70); keyboard.releaseAll(); }
void macSystemAction(int index) {
// Safe, standard macOS shortcuts: Lock, Spotlight, Mission Control, and Show Desktop.
if (index == 0) { keyboard.press(KEY_LEFT_CTRL); keyboard.press(KEY_LEFT_GUI); keyboard.press('q'); delay(70); keyboard.releaseAll(); }
if (index == 1) macShortcut(' ');
if (index == 2) { keyboard.press(KEY_LEFT_CTRL); keyboard.press(KEY_UP_ARROW); delay(70); keyboard.releaseAll(); }
if (index == 3) macShortcut('d');
}
void action(int index) {
if (mode == MODE_MOUSE) {
if (index == 0 || index == 3) {
keyboard.moveMouse(0,0,0,index == 0 ? 2 : 1); delay(15); keyboard.moveMouse(0,0);
showOLEDNotice("MOUSE", index == 0 ? "Right click" : "Left click");
}
if (index == 2) { scrollLatched = !scrollLatched; drawScreens(); }
if (index == 1) { showOLEDNotice("HOLD STILL", "Calibrating", 700); calibrateMouse(); drawScreens(); }
return;
}
if (mode == MODE_SETTINGS) {
// In Settings: button 1 moves up, button 2 moves down, button 4 enters/exits edit mode.
if (index == 0) { setting = (setting + 4) % 5; settingsEditing = false; }
else if (index == 1) { setting = (setting + 1) % 5; settingsEditing = false; }
else if (index == 3) { settingsEditing = !settingsEditing; }
drawScreens();
return;
}
if (mode == MODE_LIGHTS) {
// Lights page 1 maps the four buttons to relay switches 1–4;
// page 2 maps buttons 1 and 2 to relay switches 5 and 6.
const int channel = lightPage == 0 ? index : 4 + index;
if (channel < 0 || channel > 5) return;
sendToggle(channel);
// Display only confirmed receiver states, never an assumed toggle.
return;
}
if (!bleOnline) return;
if (mode == 0) { const char *labels[] = {"BACK 10 SEC", "PLAY / PAUSE", "FORWARD 10 SEC", "FULL SCREEN"}; showOLEDNotice("YOUTUBE", labels[index]); if (index == 0) keyboard.write('j'); if (index == 1) keyboard.write('k'); if (index == 2) keyboard.write('l'); if (index == 3) keyboard.write('f'); }
if (mode == 1) { const char *labels[] = {"PREVIOUS TRACK", "PLAY / PAUSE", "NEXT TRACK", "MUTE"}; showOLEDNotice("SPOTIFY", labels[index]); if (index == 0) keyboard.write(KEY_MEDIA_PREVIOUS_TRACK); if (index == 1) keyboard.write(KEY_MEDIA_PLAY_PAUSE); if (index == 2) keyboard.write(KEY_MEDIA_NEXT_TRACK); if (index == 3) keyboard.write(KEY_MEDIA_MUTE); }
if (mode == 2) { const char *labels[] = {"SCROLL UP", "PLAY / PAUSE", "SCROLL DOWN", "MUTE"}; showOLEDNotice("SHORTS", labels[index]); if (index == 0) keyboard.write(KEY_UP_ARROW); if (index == 1) keyboard.write(' '); if (index == 2) keyboard.write(KEY_DOWN_ARROW); if (index == 3) keyboard.write(KEY_MEDIA_MUTE); }
if (mode == 3) { const char *labels[] = {"SEEK BACK", "PLAY / PAUSE", "SEEK FORWARD", "FULL SCREEN"}; showOLEDNotice("VLC", labels[index]); if (index == 0) keyboard.write(KEY_LEFT_ARROW); if (index == 1) keyboard.write(' '); if (index == 2) keyboard.write(KEY_RIGHT_ARROW); if (index == 3) keyboard.write('f'); }
if (mode == 5) { const char *labels[] = {"PREVIOUS SLIDE", "NEXT SLIDE", "START SHOW", "BLACK SCREEN"}; showOLEDNotice("PRESENTATION", labels[index]); if (index == 0) keyboard.write(KEY_PAGE_UP); if (index == 1) keyboard.write(KEY_PAGE_DOWN); if (index == 2) keyboard.write(KEY_F5); if (index == 3) keyboard.write('b'); }
if (mode == 6) {
const char *windowsLabels[] = {"SLEEP", "RESTART", "SHUT DOWN", "SHOW DESKTOP"};
const char *macLabels[] = {"LOCK SCREEN", "SPOTLIGHT", "MISSION CONTROL", "SHOW DESKTOP"};
showOLEDNotice("SYSTEM", computerMode == 0 ? windowsLabels[index] : macLabels[index], 3000);
if (computerMode == 0) { if (index == 0) windowsPower('s'); if (index == 1) windowsPower('r'); if (index == 2) windowsPower('u'); if (index == 3) desktop(); }
else macSystemAction(index);
}
}
void checkButton(int pin, int index) {
bool pressed = digitalRead(pin) == LOW;
if (mode == MODE_LIGHTS || mode == MODE_MOUSE) {
static bool latched[4] = {false, false, false, false};
static unsigned long releasedAt[4] = {0, 0, 0, 0};
if (!pressed) { if (latched[index]) releasedAt[index] = millis(); latched[index] = false; }
else if (!latched[index] && millis() - releasedAt[index] > 60) {
latched[index] = true; action(index);
}
return;
}
if (mode == 6) { if (pressed && !holding[index]) { holding[index] = true; heldAt[index] = millis(); } else if (pressed && holding[index] && millis() - heldAt[index] >= 3000) { action(index); holding[index] = false; } else if (!pressed) holding[index] = false; return; }
if (pressed && millis() - lastButton > 180) { action(index); lastButton = millis(); }
}
void handleEncoder() {
long now = encoder.getCount();
if (now != lastEncoder) {
// Reversed volume direction: clockwise rotation lowers volume.
int change = now > lastEncoder ? -2 : 2; lastEncoder = now;
if (mode == MODE_MOUSE && scrollLatched) { keyboard.moveMouse(0, 0, change > 0 ? -1 : 1); }
else if (mode == MODE_LIGHTS) { lightPage = clampValue(lightPage + (change > 0 ? 1 : -1), 0, 1); drawScreens(); }
else if (mode == MODE_SETTINGS) {
if (!settingsEditing) { drawScreens(); return; }
if (setting == 0) {
brightness = clampValue(brightness + change, 0, 100);
} else if (setting == 1) {
lcdBacklight = change > 0;
if (lcdBacklight) lcd->backlight(); else lcd->noBacklight();
} else if (setting == 2) {
effect = clampValue(effect + (change > 0 ? 1 : -1), 0, EFFECT_COUNT - 1);
} else if (setting == 3) {
speed = clampValue(speed + change, 1, 100);
} else {
computerMode = change > 0 ? 1 : 0;
}
save(); drawScreens();
}
else { int next = clampValue(volume + change, 0, 100); if (next != volume) { int direction = next > volume ? 1 : -1; volume = next; mediaVolume(direction); save(); showOLEDNotice("VOLUME", (String(volume) + "%").c_str(), 1200); drawScreens(); } }
}
bool pushed = digitalRead(ENC_SW);
if (!pushed && lastEncoderButton) { mode = (mode + 1) % 9; scrollLatched = false; lightPage = 0; settingsEditing = false; save(); if (mode == MODE_LIGHTS) { sendQuery(); lastQuery = millis(); } drawScreens(); delay(180); }
lastEncoderButton = pushed;
}
void setup() {
Serial.begin(115200); EEPROM.begin(EEPROM_SIZE);
pinMode(BTN1, INPUT_PULLUP); pinMode(BTN4, INPUT_PULLUP);
if (digitalRead(BTN1) == LOW && digitalRead(BTN4) == LOW) { unsigned long t = millis(); while (digitalRead(BTN1) == LOW && digitalRead(BTN4) == LOW) { if (millis() - t >= 5000) resetMemory(); delay(10); } }
load(); pixels.begin(); pixels.clear(); pixels.show();
Wire.begin(I2C_SDA, I2C_SCL);
Wire.setTimeOut(25);
auto present = [](uint8_t address) {
Wire.beginTransmission(address);
return Wire.endTransmission() == 0;
};
if (present(0x27)) {
lcd = new LiquidCrystal_I2C(0x27, 20, 4);
lcd->init();
if (lcdBacklight) lcd->backlight(); else lcd->noBacklight();
}
// Probe before constructing another BusIO device: deleting an old device
// can end the shared Wire bus, affecting both displays.
uint8_t imuAddress = present(0x68) ? 0x68 : (present(0x69) ? 0x69 : 0);
if (imuAddress) imuOK = mouseIMU.begin(imuAddress, &Wire);
if (imuOK) { mouseIMU.setGyroRange(MPU6050_RANGE_250_DEG); mouseIMU.setFilterBandwidth(MPU6050_BAND_21_HZ); calibrateMouse(); }
Wire.begin(I2C_SDA, I2C_SCL);
Wire.setTimeOut(25);
oledOK = present(OLED_ADDR) && oled.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR, true, false);
Serial.printf("Hardware: LCD=%s OLED=%s MPU6050=%s\n", lcd ? "OK" : "missing", oledOK ? "OK" : "missing", imuOK ? "OK" : "missing");
lcdRow(0, "Sajan Media Ctrl"); lcdRow(1, "Initialising..."); lcdRow(2, "Sp Pedal.inc");
if (oledOK) { oled.clearDisplay(); oled.setTextColor(SSD1306_WHITE); oled.setTextSize(1); oled.setCursor(14, 5); oled.print("SAJAN MEDIA CTRL"); oled.setCursor(24, 19); oled.print("INITIALISING..."); oled.display(); }
// Three-second startup animation on the LCD's fourth row.
const unsigned long startupStart = millis();
uint8_t arrowCount = 0;
while (millis() - startupStart < 3000) {
String arrows;
for (uint8_t i = 0; i < arrowCount; ++i) arrows += '>';
lcdRow(3, arrows);
arrowCount = (arrowCount + 1) % 21;
delay(120);
}
encoder.attachHalfQuad(ENC_DT, ENC_CLK); encoder.setCount(volume); lastEncoder = encoder.getCount();
pinMode(ENC_SW, INPUT_PULLUP); pinMode(BTN2, INPUT_PULLUP); pinMode(BTN3, INPUT_PULLUP);
keyboard.begin(); WiFi.mode(WIFI_STA); WiFi.setSleep(false); Serial.print("Media controller MAC (office reply destination): "); Serial.println(WiFi.macAddress());
if (esp_now_init() == ESP_OK) { esp_now_register_send_cb(onSend); esp_now_register_recv_cb(onReceive); esp_now_peer_info_t peer = {}; memcpy(peer.peer_addr, relayMAC, 6); peer.channel = 0; peer.encrypt = false; peerAdded = esp_now_add_peer(&peer) == ESP_OK; }
drawScreens();
}
void loop() {
serviceOffice(); serviceMouse();
if (millis() - lastBLECheck >= 2000) { bool connected = keyboard.isConnected(); if (connected != bleOnline) { bleOnline = connected; drawScreens(); } lastBLECheck = millis(); }
handleEncoder(); checkButton(BTN1, 0); checkButton(BTN2, 1); checkButton(BTN3, 2); checkButton(BTN4, 3); updateLEDs();
if (oledOK && millis() < oledNoticeUntil && oledNoticeTitle == "SYSTEM" && millis() - oledAnimTick >= 180) { oledAnimTick = millis(); drawOLED(); }
delay(5);
}
// Wiring verified: GPIO21/22 serve OLED and MPU6050 directly at 3.3V; LCD through BSS138.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.




