Community project
旋钮调光灯
Generated with AIThis 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
Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.
Parts list
Bill of materials| Component | Qty | Notes |
|---|---|---|
| LED series resistor220 Ω | 1 | 一个串在 LED 前面的电阻,用来限制电流并保护 LED 和行空板引脚。 |
| LEDRed | 1 | Standard 3mm/5mm through-hole LED. A current-limiting series resistor is added automatically. |
Assembly
4 steps设置三个旋钮的地址
查看三个 SEN0502 背面的两位地址开关。把第一个旋钮设为 00(地址 0x54),第二个设为 01(地址 0x55),第三个设为 10(地址 0x56)。这样三个旋钮共用同一组线时,K10 才能分辨它们。
- Tip: 先断开 K10 的 USB 电源,再拨动地址开关。
- ⚠ 如果两个旋钮使用相同地址,它们会互相干扰,屏幕无法分别读出数值。
连接第一个旋钮
将第一个 SEN0502 的 VCC 接到 K10 I2C 接口的 3V3(供电),GND 接 GND(地线),SDA 接 SDA / GPIO47(数据),SCL 接 SCL / GPIO48(时钟)。
- Tip: 模块自带的灯环由这四根线控制,不要另接灯环电源线。
- ⚠ VCC 和 GND 接反或把 VCC 接到 5V,可能损坏旋钮模块。
并联连接另外两个旋钮
第二、第三个 SEN0502 同样接到同一组 I2C 插座:每个 VCC 接 3V3(供电)、每个 GND 接 GND(地线)、每个 SDA 接 SDA / GPIO47(数据)、每个 SCL 接 SCL / GPIO48(时钟)。
- Tip: 三块模块的 SDA 可以接在同一排孔中,SCL 也可以接在同一排孔中;这是共享通信线的正常接法。
- ⚠ 不要把 SDA 与 SCL 交叉接错,否则三个模块都不能正常通信。
查看灯环和圈数
用 USB 给 K10 供电。转动第一个旋钮时,它的自带灯环会以 51 档显示进度;每次经过一个有卡点的位置,进度会增加一格。第一个旋钮的计数从接近 1023 回到接近 0 时,K10 屏幕上的 Completed turns 会加一。屏幕也继续显示三个旋钮的直接读数。
- Tip: 计数从 0 开始;重启或重新部署后,圈数会重新从 0 统计。
- ⚠ 转动太快而跨过回零位置时,程序可能来不及捕捉一次完整循环;请以正常手动速度转动。
Pin assignments
Board wiring reference| Pin | Connection | Type |
|---|---|---|
| GPIO 2 | led_resistor End 1 | digital |
| EXT | led_resistor End 2 → LED ANODE | digital |
| GND | external_led GND | ground |
Firmware
ESP32#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.