Knob на ESP32 (круглый OLED 1.28″): управляем Yeelight энкодером
В прошлой статье я разобрал локальный протокол Yeelight и научился рулить своей монитор-лампой Screen Light Bar Pro прямо из терминала — JSON-команды по TCP, без облака и приложения. Это здорово, но каждый раз лезть в консоль, чтобы убавить яркость, — так себе UX.
С лампой, кстати, в комплекте уже идёт беспроводная крутилка-пульт — крутишь, меняется яркость. Удобно, но это закрытое решение: своего экрана нет, логику не поменять, а до задней RGB-подсветки штатно не дотянуться. Захотелось сделать свою крутилку — открытую, со своим экраном и полным контролем над обеими зонами.
++++
Под рукой оказалась подходящая плата — круглый дисплей 1.28" на ESP32-C3 с поворотным энкодером и кнопкой, тот самый «knob». В этой статье я завожу его с нуля, набиваю все грабли (а их хватает) и в итоге делаю прошивку, которая управляет обеими зонами лампы — основным светом и задней RGB-подсветкой — напрямую по сети. В конце — две отладочные истории, ради которых пришлось повозиться: дёргающаяся яркость и подсветка, которая упорно не включается, плюс отдельно — как уложиться в лимит команд лампы.
Изначально я метил в LVGL, но для списка из шести параметров это оверкилл. В итоге обошёлся связкой Adafruit GFX + U8g2 — легче, без лишних зависимостей и с нормальной кириллицей. И, кстати: панель здесь — IPS LCD на GC9A01, хотя плату часто продают как «OLED». Весь код — в репозитории
esp32-viewe-knob-yeelight.
Что за железо
Плата — VIEWE UEDX24240013-MD50E: ESP32-C3, круглый IPS-дисплей 240×240 на контроллере GC9A01 (4-проводной SPI), поворотный энкодер и кнопка (нажатие самой ручки). Готовый форм-фактор для настольного контроллера.
Распиновку по коду не угадать — она зашита в плату, так что держим её под рукой:
- SPI SCLK — GPIO1 (нестандартный пин, задаётся явно:
SPI.begin(1, -1, 0, 10)) - SPI MOSI/SDA — GPIO0
- CS — GPIO10, DC — GPIO4, RST — не разведён (программный сброс, -1)
- Подсветка (BL) — GPIO8, инверсная: LOW = включена
- Энкодер: PHA — GPIO7, PHB — GPIO6
- Кнопка — GPIO9, active-LOW, делит пин с boot-strapping
Самая коварная грабля платы — инверсная подсветка. Код может работать идеально, дисплей рисовать кадры, но экран будет чёрным, если на GPIO8 подать HIGH. Всегда выставляй LOW. На этом легко потерять час, гадая «почему не загружается».
Окружение сборки
Собираю через arduino-cli и обычный Makefile — это самый воспроизводимый вариант для читателя. Плата компилируется под FQBN esp32:esp32:esp32c3:CDCOnBoot=cdc (в Arduino IDE это «ESP32C3 Dev Module» + «USB CDC On Boot: Enabled»). Нужны три библиотеки:
$ arduino-cli lib install "Adafruit GFX Library" "Adafruit GC9A01A" "U8g2_for_Adafruit_GFX"
Ещё одна засада — Serial. На этой плате нативный USB Serial/JTAG (CDC), и arduino-cli monitor часто просто молчит. Я завёл скрипт на pyserial, который и сбрасывает плату по линиям порта, и читает лог:
$ python3 serial_tool.py monitor 10 # читать Serial 10 секунд$ python3 serial_tool.py reset # аппаратный сброс
Первый текст на экране
Минимальная цель — вывести строку. И сразу вторая грабля: встроенный шрифт Adafruit GFX не умеет кириллицу. Совсем. Лечится подключением U8g2_for_Adafruit_GFX со шрифтом из набора *_t_cyrillic, а файл скетча обязан быть в UTF-8. Ключевые строки setup():
123456789101112
pinMode(TFT_BL, OUTPUT);
digitalWrite(TFT_BL, LOW); // подсветка инверсная: LOW = ВКЛ
SPI.begin(TFT_SCLK, -1, TFT_MOSI, TFT_CS);
tft.begin();
tft.setRotation(0);
u8g2.begin(tft);
u8g2.setFont(u8g2_font_10x20_t_cyrillic); // GFX-шрифт кириллицу не нарисует
u8g2.setForegroundColor(GC9A01A_WHITE);
u8g2.setCursor(20, 120);
u8g2.print("Привет, читатель моего блога!");
Имя класса — U8G2_FOR_ADAFRUIT_GFX (заглавными). U8g2_for_Adafruit_GFX — это только название заголовочника, и попытка объявить им объект не скомпилируется. Мелочь, а ловит.
Энкодер без дребезга
Дешёвые энкодеры EC11 выдают 2 фронта на один щелчок и охотно дребезжат. Наивный счёт по одному фронту даёт «двойные» и «прыгающие» значения. Правильное решение — полушаговый конечный автомат Бена Бакстона: он выдаёт ровно +1/−1 на щелчок и гасит дребезг прямо в обработчике прерывания.
12345678910111213
// Таблица переходов — в DRAM, обработчик — в IRAM (иначе обращение к флешу
// из прерывания может уронить ESP32-C3).
DRAM_ATTR static const uint8_t ttable[6][4] = { /* ... полушаговая таблица ... */ };
volatile uint8_t qstate = 0;
volatile long encDetents = 0; // позиция в щелчках
void IRAM_ATTR onEncoder() {
uint8_t pin = (digitalRead(ENC_A) << 1) | digitalRead(ENC_B);
qstate = ttable[qstate & 0x07][pin];
uint8_t dir = qstate & 0x30;
if (dir == DIR_CW) encDetents--; // подобрано так, чтобы вправо = +
else if (dir == DIR_CCW) encDetents++;
}
В loop() остаётся только считать разницу encDetents с прошлого раза и применить её к активному параметру. Никакой логики дребезга в основном цикле — всё уже сделано в ISR.
Достучаться до лампы по Wi-Fi
Протокол я уже разбирал в статье про лампу: TCP на порт 55443, JSON-команды с \r\n на конце. На ESP32 повторяю ровно это, только держу одно постоянное соединение (открывать новое на каждую команду — дорого и упирается в лимиты лампы).
Wi-Fi-пароль и IP лампы в публичном репозитории светить нельзя, поэтому они вынесены в secrets.h — он в .gitignore, а в репозиторий едет только шаблон secrets.h.example. Важный нюанс именно для Arduino: #include "secrets.h" ищет файл в папке скетча, а не в корне репозитория.
123456789
// Одна команда без разбора ответа — на статических буферах, без String в цикле.
bool sendRaw(const char *method, const char *params) {
if (lampIp.isEmpty() || !ensureConnected()) return false;
char cmd[160];
snprintf(cmd, sizeof(cmd),
"{\"id\":1,\"method\":\"%s\",\"params\":[%s]}\r\n", method, params);
lampClient.print(cmd);
return true;
}
Модель управления
Интерфейс — список из шести параметров. Короткое нажатие ручки листает активный параметр, поворот меняет его значение:
- Свет — вкл/выкл (поворот вправо — включить, влево — выключить)
- Яркость — 1–100% (
set_bright) - Темп-ра — 2700–6500K (
set_ct_abx) - Подсветка — вкл/выкл задней RGB-зоны
- Зад. яркость — яркость подсветки (
bg_set_bright) - Цвет — оттенок подсветки, hue 0–359 (
bg_set_hsv), с кружком-образцом на экране
Перерисовываю только ту строку, что изменилась, — без fillScreen на каждый кадр, иначе экран мерцает. А дальше начался реальный дебаг, ради которого всё и затевалось.
История №1: яркость дёргается
Первая рабочая версия крутила яркость командой set_bright с плавным переходом "smooth", 300. На бумаге красиво, на деле — лампа дёргается и отстаёт от ручки, будто подвисает. Серийный лог и тайминги цикла были чистыми, сеть — тоже. Значит дело не в прошивке, а в том, как лампа исполняет команды.
Разгадка: "smooth", 300 велит лампе плавно гаснуть/разгораться 300 мс. Но при кручении новая команда прилетает раньше, чем через 300 мс. Лампа прерывает текущий переход и стартует новый от текущего реального уровня — она всегда «в процессе», всегда позади ручки. Для крутилки нужен мгновенный отклик:
1234
// было: "%d,\"smooth\",300" -> лампа дёргается
// стало:
snprintf(params, sizeof(params), "%d,\"sudden\",30", st.bright);
sendRaw("set_bright", params); // sudden = мгновенно, ручка отслеживается чётко
История №2: подсветка не включается
Дальше выяснилось, что задняя подсветка не включается с контроллера — только родным пультом. Причём команда bg_set_power ["on"] возвращает честный {"result":["ok"]}, но bg_power остаётся off. Я проверил всё с Mac, читая ответы — и наткнулся на тройку флагов питания, которая всё объясняет:
power — общий флаг устройства: on, если жива любая зонаmain_power — собственно основной светbg_power — задняя подсветка
Первый вывод: для статуса основного света надо читать main_power, а не power — иначе индикатор врёт. Но это не решало главного: из «спящего» состояния bg_set_power "on" подсветку не будит. Зато её надёжно поднимает bg_set_scene — эта команда атомарно делает «питание + режим» одним пакетом (пульт почти наверняка шлёт именно её):
123456
// Надёжное включение подсветки: сцена включает зону И задаёт цвет/яркость разом.
void bgTurnOn() {
char params[48];
snprintf(params, sizeof(params), "\"hsv\",%d,100,%d", st.hue, st.bgBright);
sendRaw("bg_set_scene", params);
}
Проверка показала и приятное: зоны полностью независимы — основной свет и подсветка могут гореть одновременно, разными цветами и яркостью. Никакого «или-или», как кажется из приложения.
Бережём лимит лампы
У лампы есть лимит — примерно 60 команд в минуту, и держит она около четырёх TCP-соединений. Энкодер же легко выдаёт два десятка щелчков в секунду. Слать команду на каждый щелчок нельзя — лампа начнёт отбрасывать пакеты. Поэтому значение крутится локально мгновенно (и сразу видно на экране), а в лампу уезжает с дебаунсом: после паузы ~200 мс, либо принудительно раз в секунду при непрерывном кручении. Целый прокрут от 40% до 80% схлопывается в одну-две команды вместо сорока.
1234567891011
void commitPending() {
if (!pending) return;
uint32_t now = millis();
bool idle = now - lastChangeMs >= COMMIT_IDLE; // пользователь остановился
bool forced = now - lastSendMs >= COMMIT_MAX; // долгое непрерывное кручение
if ((idle || forced) && now - lastSendMs >= MIN_GAP) {
sendValue(pendingId);
lastSendMs = now;
pending = false;
}
}
Соединение с лампой держим постоянным и постоянно вычитываем из него входящие данные: на toggle и set_power лампа шлёт не только result, но и асинхронные props-уведомления в том же сокете. Если их не читать, буфер забивается.
++++
Что в итоге
Получилась настольная крутилка, которая одним движением рулит и основным светом, и задней подсветкой Yeelight — без облака, без приложения, напрямую по локальной сети. И, в отличие от штатной крутилки, эта — со своим экраном, живым состоянием обеих зон и полностью открытым кодом. Весь код, распиновка и подробный разбор граблей — в репозитории: github.com/mblog-repository/esp32-viewe-knob-yeelight.
Что напрашивается дальше: убрать лимит команд через music mode (udp_sess_new, когда лампа сама подключается к твоему сокету) — тогда можно гонять плавные переливы без оглядки на квоту. А ещё в поле support у этой лампы есть set_segment_rgb — посегментная адресация подсветки, которую тоже хочется покрутить этой же ручкой. Но это уже темы для отдельных заметок.