Community project

旋钮调光灯

聂小英

Published August 18, 2026

ESP32
Photo of 旋钮调光灯Generated with AI

This project uses three rotary encoders connected to an ESP32 to control LED brightness with visual feedback. Each encoder has a ring of LEDs that illuminate to show the current brightness level, with different sensitivity settings for fine and coarse adjustments.

The guide provides a complete wiring diagram, parts list, and step-by-step assembly instructions for connecting the encoders via I2C and configuring their I2C addresses. Firmware code is included to read encoder values, update the LED rings, and display real-time brightness information on the ESP32's built-in display.

Wiring diagram

Interactive · read-only
Wiring diagram for 旋钮调光灯

Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.

Parts list

Bill of materials
ComponentQtyNotes
LED series resistor220 Ω1一个串在 LED 前面的电阻,用来限制电流并保护 LED 和行空板引脚。
LEDRed1Standard 3mm/5mm through-hole LED. A current-limiting series resistor is added automatically.

Assembly

4 steps
  1. 设置三个旋钮的地址

    查看三个 SEN0502 背面的两位地址开关。把第一个旋钮设为 00(地址 0x54),第二个设为 01(地址 0x55),第三个设为 10(地址 0x56)。这样三个旋钮共用同一组线时,K10 才能分辨它们。

    • Tip: 先断开 K10 的 USB 电源,再拨动地址开关。
    • 如果两个旋钮使用相同地址,它们会互相干扰,屏幕无法分别读出数值。
  2. 连接第一个旋钮

    将第一个 SEN0502 的 VCC 接到 K10 I2C 接口的 3V3(供电),GND 接 GND(地线),SDA 接 SDA / GPIO47(数据),SCL 接 SCL / GPIO48(时钟)。

    • Tip: 模块自带的灯环由这四根线控制,不要另接灯环电源线。
    • VCC 和 GND 接反或把 VCC 接到 5V,可能损坏旋钮模块。
  3. 并联连接另外两个旋钮

    第二、第三个 SEN0502 同样接到同一组 I2C 插座:每个 VCC 接 3V3(供电)、每个 GND 接 GND(地线)、每个 SDA 接 SDA / GPIO47(数据)、每个 SCL 接 SCL / GPIO48(时钟)。

    • Tip: 三块模块的 SDA 可以接在同一排孔中,SCL 也可以接在同一排孔中;这是共享通信线的正常接法。
    • 不要把 SDA 与 SCL 交叉接错,否则三个模块都不能正常通信。
  4. 查看灯环和圈数

    用 USB 给 K10 供电。转动第一个旋钮时,它的自带灯环会以 51 档显示进度;每次经过一个有卡点的位置,进度会增加一格。第一个旋钮的计数从接近 1023 回到接近 0 时,K10 屏幕上的 Completed turns 会加一。屏幕也继续显示三个旋钮的直接读数。

    • Tip: 计数从 0 开始;重启或重新部署后,圈数会重新从 0 统计。
    • 转动太快而跨过回零位置时,程序可能来不及捕捉一次完整循环;请以正常手动速度转动。

Pin assignments

Board wiring reference
PinConnectionType
GPIO 2led_resistor End 1digital
EXTled_resistor End 2LED ANODEdigital
GNDexternal_led GNDground

Firmware

ESP32
main.cppDeploy to device
#include <Arduino.h>
#include <Wire.h>
#include <unihiker_k10.h>
#include <lvgl.h>
#include <DFRobot_VisualRotaryEncoder.h>


// Forward declarations
void setLabelText(lv_obj_t *label, const char *format, ...);
void styleLabel(lv_obj_t *label, const lv_font_t *font, uint32_t color, int width, lv_text_align_t align);
void addHorizontalDivider(int y);
void initialiseEncoderA();
void connectEncoders();
void updateEncoderA();
void updateEncoderB();
void updateEncoderC();
lv_obj_t *createRow(int row, const char *title, const char *mode, uint32_t color);
uint8_t ringLedCount(uint16_t value);

constexpr int I2C_SDA_PIN = 47;
constexpr int I2C_SCL_PIN = 48;
constexpr uint8_t ADDRESS_A = 0x54;
constexpr uint8_t ADDRESS_B = 0x55;
constexpr uint8_t ADDRESS_C = 0x56;
constexpr uint16_t ONE_TURN_VALUE = 1023;
constexpr uint8_t B_STEP_COUNT = 5;
constexpr uint8_t C_STEP_COUNT = 1;
constexpr uint8_t RING_LED_COUNT = 20;
// One complete 20-LED fill represents these mechanical turns.
constexpr uint8_t A_TURNS_PER_FULL_RING = 1;
constexpr uint8_t B_TURNS_PER_FULL_RING = 10;
constexpr uint8_t C_TURNS_PER_FULL_RING = 50;
constexpr unsigned long READ_INTERVAL_MS = 20;
constexpr unsigned long RETRY_INTERVAL_MS = 1000;
constexpr int SCREEN_WIDTH = 320;
constexpr int SCREEN_HEIGHT = 240;
constexpr int ROW_HEIGHT = 80;

UNIHIKER_K10 k10;
DFRobot_VisualRotaryEncoder_I2C encoderA(ADDRESS_A, &Wire);
DFRobot_VisualRotaryEncoder_I2C encoderB(ADDRESS_B, &Wire);
DFRobot_VisualRotaryEncoder_I2C encoderC(ADDRESS_C, &Wire);

lv_obj_t *countALabel;
lv_obj_t *countBLabel;
lv_obj_t *countCLabel;
lv_obj_t *valueALabel;
lv_obj_t *valueBLabel;
lv_obj_t *valueCLabel;
lv_obj_t *barA;
lv_obj_t *barB;
lv_obj_t *barC;
// Right-side heading labels show each encoder's live raw position out of 1023.
lv_obj_t *modeALabel;
lv_obj_t *modeBLabel;
lv_obj_t *modeCLabel;

bool encoderAReady = false;
bool encoderBReady = false;
bool encoderCReady = false;
uint16_t lastA = 65535;
uint16_t lastB = 65535;
uint16_t lastC = 65535;
// Turns completed before the most recent encoder reset.
float storedTurnsA = 0.0f;
float storedTurnsB = 0.0f;
float storedTurnsC = 0.0f;
unsigned long lastReadTime = 0;
unsigned long lastRetryTime = 0;

void setLabelText(lv_obj_t *label, const char *format, ...) {
  char text[48];
  va_list args;
  va_start(args, format);
  vsnprintf(text, sizeof(text), format, args);
  va_end(args);
  lv_label_set_text(label, text);
}

void styleLabel(lv_obj_t *label, const lv_font_t *font, uint32_t color, int width, lv_text_align_t align) {
  lv_obj_set_style_text_font(label, font, 0);
  lv_obj_set_style_text_color(label, lv_color_hex(color), 0);
  lv_obj_set_style_text_align(label, align, 0);
  lv_obj_set_width(label, width);
}

void addHorizontalDivider(int y) {
  lv_obj_t *line = lv_obj_create(lv_scr_act());
  lv_obj_set_size(line, SCREEN_WIDTH, 1);
  lv_obj_set_pos(line, 0, y);
  lv_obj_set_style_bg_color(line, lv_color_hex(0xA0A0A0), 0);
  lv_obj_set_style_border_width(line, 0, 0);
  lv_obj_clear_flag(line, LV_OBJ_FLAG_SCROLLABLE);
}

lv_obj_t *createProgressBar(int y, uint32_t color) {
  lv_obj_t *bar = lv_bar_create(lv_scr_act());
  lv_obj_set_size(bar, 124, 14);
  lv_obj_set_pos(bar, 184, y);
  lv_bar_set_range(bar, 0, ONE_TURN_VALUE);
  lv_bar_set_value(bar, 0, LV_ANIM_OFF);
  lv_obj_set_style_bg_color(bar, lv_color_hex(0xE4E4E4), LV_PART_MAIN);
  lv_obj_set_style_bg_opa(bar, LV_OPA_COVER, LV_PART_MAIN);
  lv_obj_set_style_bg_color(bar, lv_color_hex(color), LV_PART_INDICATOR);
  lv_obj_set_style_border_color(bar, lv_color_hex(0x303030), LV_PART_MAIN);
  lv_obj_set_style_border_width(bar, 1, LV_PART_MAIN);
  return bar;
}

void initialiseEncoderA() {
  encoderAReady = (encoderA.begin() == NO_ERR);
  if (encoderAReady) {
    encoderA.setGainCoefficient(51);
    lastA = 65535;
  }
}

void connectEncoders() {
  if (!encoderAReady) initialiseEncoderA();
  if (!encoderBReady) {
    encoderBReady = (encoderB.begin() == NO_ERR);
    if (encoderBReady) encoderB.setGainCoefficient(5);
  }
  if (!encoderCReady) {
    encoderCReady = (encoderC.begin() == NO_ERR);
    if (encoderCReady) encoderC.setGainCoefficient(1);
  }
}

void updateEncoderA() {
  uint16_t value = encoderA.getEncoderValue();
  if (encoderA.detectButtonDown()) {
    // Keep the part already travelled, then start the next measurement at zero.
    storedTurnsA += (static_cast<float>(value) / ONE_TURN_VALUE) * A_TURNS_PER_FULL_RING;
    encoderA.setEncoderValue(0);
    value = 0;
    lastA = 65535;
  }

  const float turns = storedTurnsA +
                      (static_cast<float>(value) / ONE_TURN_VALUE) * A_TURNS_PER_FULL_RING;
  if (value != lastA) {
    lastA = value;
    lv_bar_set_value(barA, value, LV_ANIM_OFF);
    setLabelText(modeALabel, "%u / 1023", value);
  }
  setLabelText(countALabel, "Turns: %.2f", turns);
}

void updateEncoderB() {
  uint16_t value = encoderB.getEncoderValue();
  if (encoderB.detectButtonDown()) {
    // Preserve partial turns as well as completed turns when resetting B.
    storedTurnsB += (static_cast<float>(value) / ONE_TURN_VALUE) * B_TURNS_PER_FULL_RING;
    encoderB.setEncoderValue(0);
    value = 0;
    lastB = 65535;
  }

  const float turns = storedTurnsB +
                      (static_cast<float>(value) / ONE_TURN_VALUE) * B_TURNS_PER_FULL_RING;
  if (value != lastB) {
    lastB = value;
    lv_bar_set_value(barB, value, LV_ANIM_OFF);
    setLabelText(modeBLabel, "%u / 1023", value);
  }
  setLabelText(countBLabel, "Turns: %.1f", turns);
}

void updateEncoderC() {
  uint16_t value = encoderC.getEncoderValue();
  if (encoderC.detectButtonDown()) {
    // Preserve partial turns as well as completed turns when resetting C.
    storedTurnsC += (static_cast<float>(value) / ONE_TURN_VALUE) * C_TURNS_PER_FULL_RING;
    encoderC.setEncoderValue(0);
    value = 0;
    lastC = 65535;
  }

  const float turns = storedTurnsC +
                      (static_cast<float>(value) / ONE_TURN_VALUE) * C_TURNS_PER_FULL_RING;
  if (value != lastC) {
    lastC = value;
    lv_bar_set_value(barC, value, LV_ANIM_OFF);
    setLabelText(modeCLabel, "%u / 1023", value);
  }
  setLabelText(countCLabel, "Turns: %.1f", turns);
}

lv_obj_t *createRow(int row, const char *title, const char *mode, uint32_t color) {
  const int y = row * ROW_HEIGHT;

  lv_obj_t *titleLabel = lv_label_create(lv_scr_act());
  lv_obj_t *modeLabel = lv_label_create(lv_scr_act());
  styleLabel(titleLabel, &lv_font_montserrat_14, color, 166, LV_TEXT_ALIGN_LEFT);
  lv_label_set_text(titleLabel, title);
  lv_label_set_text(modeLabel, mode);
  // Place the encoder heading lower within the top area of each row.
  lv_obj_set_pos(titleLabel, 10, y + 20);

  // Centre the live raw reading directly over this row's progress bar.
  styleLabel(modeLabel, &lv_font_montserrat_14, 0x101010, 124, LV_TEXT_ALIGN_CENTER);
  lv_obj_set_pos(modeLabel, 184, y + 48);
  return modeLabel;
}

uint8_t ringLedCount(uint16_t value) {
  return (uint8_t)((uint32_t)value * RING_LED_COUNT / ONE_TURN_VALUE);
}

void setup() {
  k10.begin();
  // Rotation 3 is the 180-degree counterpart of landscape rotation 1.
  k10.initScreen(3);
  Wire.begin(I2C_SDA_PIN, I2C_SCL_PIN);

  lv_obj_set_style_bg_color(lv_scr_act(), lv_color_hex(0xFFFFFF), 0);
  lv_obj_set_style_bg_opa(lv_scr_act(), LV_OPA_COVER, 0);

  addHorizontalDivider(ROW_HEIGHT);
  addHorizontalDivider(ROW_HEIGHT * 2);
  modeALabel = createRow(0, "A Encoder", "-- / 1023", 0xEA7B17);
  modeBLabel = createRow(1, "B Encoder", "-- / 1023", 0x3978C6);
  modeCLabel = createRow(2, "C Encoder", "-- / 1023", 0x588E5C);

  countALabel = lv_label_create(lv_scr_act());
  countBLabel = lv_label_create(lv_scr_act());
  countCLabel = lv_label_create(lv_scr_act());
  valueALabel = lv_label_create(lv_scr_act());
  valueBLabel = lv_label_create(lv_scr_act());
  valueCLabel = lv_label_create(lv_scr_act());

  lv_obj_t *countLabels[] = {countALabel, countBLabel, countCLabel};
  lv_obj_t *valueLabels[] = {valueALabel, valueBLabel, valueCLabel};
  for (lv_obj_t *label : countLabels) styleLabel(label, &lv_font_montserrat_14, 0x101010, 155, LV_TEXT_ALIGN_LEFT);
  for (lv_obj_t *label : valueLabels) styleLabel(label, &lv_font_montserrat_14, 0x101010, 155, LV_TEXT_ALIGN_LEFT);

  // Align each Turns label vertically with its row's progress bar.
  lv_obj_set_pos(countALabel, 10, 48);
  lv_obj_set_pos(valueALabel, 10, 69);
  lv_obj_set_pos(countBLabel, 10, 128);
  lv_obj_set_pos(valueBLabel, 10, 149);
  lv_obj_set_pos(countCLabel, 10, 208);
  lv_obj_set_pos(valueCLabel, 10, 229);

  barA = createProgressBar(48, 0xEA7B17);
  barB = createProgressBar(128, 0x3978C6);
  barC = createProgressBar(208, 0x588E5C);

  // Bars are created after the labels, so explicitly keep the live readings visible above them.
  lv_obj_move_foreground(modeALabel);
  lv_obj_move_foreground(modeBLabel);
  lv_obj_move_foreground(modeCLabel);

  lv_label_set_text(countALabel, "Turns: 0");
  lv_label_set_text(countBLabel, "Turns: 0");
  lv_label_set_text(countCLabel, "Turns: 0");
  lv_label_set_text(valueALabel, "");
  lv_label_set_text(valueBLabel, "");
  lv_label_set_text(valueCLabel, "");

  connectEncoders();
}

void loop() {
  lv_timer_handler();
  const unsigned long now = millis();

  if (now - lastRetryTime >= RETRY_INTERVAL_MS) {
    lastRetryTime = now;
    connectEncoders();
  }
  if (now - lastReadTime < READ_INTERVAL_MS) return;
  lastReadTime = now;

  if (encoderAReady) updateEncoderA();
  if (encoderBReady) updateEncoderB();
  if (encoderCReady) updateEncoderC();
}

“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.

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