Лампу в спальне я долго делал на потенциометре и долго же с ним мирился: у ручки есть упоры — выкрутил в ноль и крутишь вверх мимо упора, — положение теряется при перезапуске, а механика дорожек со временем начинает шуршать. Энкодер закрыл все три претензии сразу: ручка крутится бесконечно в обе стороны, положения «ноль» у неё нет в принципе, а щелчки ощущаются пальцами лучше любой плёночки. KY-040 — это модуль с самой ручкой, парой линий тиков и кнопкой под большим пальцем; из него собираются и лампы, и термостаты, и меню настройки, но лампа — классика жанра, с неё и начнём.
Квадратурный сигнал энкодера — тема со своими граблями, и я разбирал её целиком в уроке про EC11 на прерываниях: там таблица переходов, честный подсчёт обоих каналов и война с фантомными шагами. Здесь задача проще — лампа живёт на одном тике за щелчок, — поэтому берём облегчённую схему и вкладываем силы в то, чего у потенциометра не было: плавный ход яркости к цели.
KY-040: тики, а не абсолютное значение
Потенциометр отвечает на вопрос «где ручка», энкодер — на вопрос «куда и на сколько её крутнули». Модуль KY-040 даёт три сигнала: CLK — импульс на каждый щелчок, DT — уровень, по которому читается направление в момент фронта, и SW — кнопка под нажатием на ось. По паспорту модуля на оборот приходится двадцать фиксированных щелчков и столько же импульсов CLK, питание — от 3,3 до 5 В, а подтяжки по 10 кОм на всех трёх линиях уже стоят на плате модуля: наружные резисторы не нужны. Из этого следует непривычное: яркость живёт не в детали, а в переменной скетча — после перезапуска счётчик начинается с вашего дефолта, у меня это 120 из 255.
Симулятор подсовывает упрощённую, но честную по смыслу механику: один фронт на CLK за щелчок, а DT в этот момент держит направление — при повороте по часовой HIGH, против — LOW. На реальном модуле фазы могут оказаться зеркальными, и это не поломка: если ручка крутит яркость в обратную сторону, просто поменяйте знак в обработчике. Для честной квадратуры с двумя каналами и таблицей переходов дорога ведёт в урок про attachInterrupt и в тот самый урок про EC11 — механику прерываний здесь предполагаю известной.
Плавный ход: цель и догоняющий loop
Самая короткая реализация — менять яркость прямо в обработчике прерывания: тик, плюс восемь, show(). Я так и делал, и лампа получилась дёрганой: двадцать щелчков на оборот — двадцать скачков яркости. Рабочая схема длиннее на одну переменную: тик только ставит цель — target = target ± 8 с зажимом в 0-255, — а loop() каждые 15 миллисекунд делает шаг в 4 единицы к этой цели. Полный ход от нуля до 255 — это 64 шага и около секунды на дорогу: фирменная мягкость диммеров, только без таймеров и без брокера. Схема ровно та же, что у MQTT-светильника с плавным переходом, с той разницей, что цель приходит не из payload топика, а от ручки — но локально, без всякой сети.
Числа подобраны под палец. Восемь единиц на тик — тридцать два щелчка на весь ход: комфортный жест «от темноты к чтению» укладывается в пол-оборота ручки. Шаг 4 за 15 мс — свет доезжает за секунду, но без мелкой дрожи: каждый кадр виден глазу как спокойный прирост, а не как вспышка. Захотите резче — поднимите шаг до 8, полный ход уляжется в полсекунды; медленнее — шаг 2 и дорога в две секунды, моё любимое для ночника.
Быстрое кручение это схема не ломает, а демонстрирует: тики сыпятся чаще, target уезжает вперёд, а loop дотягивает свет со своей постоянной скоростью — до 4 единиц за 15 мс, то есть около 266 единиц в секунду, быстрее любого пальца. Разрыв между целью и светом в этот момент — не баг, а очередь заданий в одну переменную: докрутите и отпустите, лампа сама приедет в точку, где ручку оставили. Именно это поведение отличает прибор от переключателя, который просто прыгает на восемь единиц за щелчок.
SW, тепло и холод, ноль без detach
Кнопка SW под осью ручки — вторая ручка этой лампы, и у неё одно-единственное право: переключать белый с тёплого на холодный. Тёплый — CRGB(255, 170, 90), про него я писал в уроке про диммер; холодный — CRGB(150, 190, 255), синему каналу здесь главная роль, и глаз читает это как «рабочий свет» или «лунный». Пропорции собираются сознательно, а не подбором: механика CRGB с тремя каналами и типовые пропорции разложены в уроке про CRGB и fill_solid. Читается SW как обычная кнопка на INPUT_PULLUP с фронтом и гейтом 50 мс — механика кнопки без изменений, просто ручка теперь вращается.
Самое интересное — ноль. Когда тек считается до нуля, скетч зовёт FastLED.clear(): лента гаснет целиком, а обработчик прерывания продолжает жить и считать тики. Вариант «отключить» через detachInterrupt я пробовал и похоронил: после отцепления первый щелчок против часовой на нуле теряется без следа, и лампа отказывается включаться ровно тогда, когда вы крутили ручку вслепую. Clear при нуле — честный аналог детача: ток пикселей падает до нуля, скетч слушает ручку дальше, и один тик вверх возвращает свет. Между прочим, ещё и повод помнить про бюджет тока: нулевая яркость — единственный режим, где лента не ест ничего.
#include <FastLED.h> #define LED_PIN 6#define NUM_LEDS 16#define PIN_CLK 2#define PIN_DT 3#define PIN_SW 4 CRGB leds[NUM_LEDS];volatile int target = 120; // цель из последнего тикаint tek = 0; // яркость на ленте сейчасint warm = 1; // 1 тёплый, 0 холодныйunsigned long lastStep = 0;unsigned long lastEdge = 0;int lastSw = HIGH; void tick() { // только числа: быстро и без show() if (digitalRead(PIN_DT) == HIGH) { target += 8; } else { target -= 8; } if (target > 255) { target = 255; } if (target < 0) { target = 0; }} void paint() { if (tek == 0) { // ноль: гасим буфер, слушаем ручку дальше FastLED.clear(); FastLED.show(); return; } if (warm == 1) { fill_solid(leds, NUM_LEDS, CRGB(255, 170, 90)); // тёплый } else { fill_solid(leds, NUM_LEDS, CRGB(150, 190, 255)); // холодный } FastLED.setBrightness(tek); FastLED.show();} void setup() { pinMode(PIN_CLK, INPUT); pinMode(PIN_DT, INPUT); pinMode(PIN_SW, INPUT_PULLUP); attachInterrupt(digitalPinToInterrupt(PIN_CLK), tick, RISING); FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS); Serial.begin(9600);} void loop() { if (tek != target && millis() - lastStep >= 15) { // догоняем цель шагом lastStep = millis(); if (tek < target) { tek += 4; if (tek > target) { tek = target; } } else { tek -= 4; if (tek < target) { tek = target; } } paint(); Serial.print("brt="); Serial.println(tek); } int sw = digitalRead(PIN_SW); // кнопка ручки: тепло/холод if (sw != lastSw && millis() - lastEdge > 50) { lastEdge = millis(); lastSw = sw; if (sw == LOW) { warm = 1 - warm; paint(); Serial.println(warm == 1 ? "cvet=warm" : "cvet=cool"); } }}| Действие | Что делает скетч | Числа |
|---|---|---|
| Тик по часовой | target + 8 с зажимом в 255 | 32 тика на весь ход |
| Тик против часовой | target − 8 с зажимом в 0 | при нуле дальше не уходит |
| Догоняющий loop | шаг 4 единицы к цели | 15 мс на шаг, полный ход около секунды |
| Нажатие SW | тёплый ↔ холодный | CRGB(255, 170, 90) ↔ CRGB(150, 190, 255) |
| Ноль яркости | FastLED.clear(), ручка слушает | ток пикселей — ноль |
Одна грабля остаётся на реальной макетке: дребезг самих щелчков. У KY-040 контакты механические, и при длинных проводах один щелчок иногда даёт два фронта подряд — яркость прыгает на шестнадцать единиц, будто вы крутнули дважды. Подтяжки модуля гасят это не всегда; лечение то же, что у любого быстрого источника: окно в одну-две миллисекунды в обработчике или конденсатор вдоль CLK — механику отсечки внутри ISR мы разбирали в уроке про антидребезг в прерывании. Окно там собрано на той же разности millis() и метки, только в масштабе одной миллисекунды, а не пятидесяти: быстрые источники фильтрует время, человеческие — порог.
И последнее — про размер лампы. Шестнадцать пикселей в симуляторе — учебный формат; на реальной ленте в деревянном бруске я ставлю 24-32: тёплый белый на такой длине уже освещает страницу книги, а ток при обычных яркостях держится в пределах пары сотен миллиампер. Всё, что выше, — уже вопрос блока питания, а не ручки.
Кластер управления руками на этом сошёлся в полный набор: непрерывная ручка с фильтром, кнопка-переключатель сцен, энкодер с плавным ходом. Все три прибора держатся на одном и том же скелете — millis-цикл, который никогда не спит, буфер, который меняет только одна ручка за раз, и числа с зажимами на границах. Возьмите любой из трёх скетчей и добавьте четвёртую ручку — это лучший экзамен на понимание каркаса: каркас не должен заметить гостя.
- KY-040: CLK — импульс на щелчок, DT — направление, SW — кнопка; 20 щелчков на оборот, подтяжки 10 кОм на модуле
- Яркость живёт в переменной скетча, не в детали: после перезапуска счётчик стартует с дефолта
- Тик ставит цель ±8, loop доводит шагом 4 за 15 мс — полный ход 0-255 занимает около секунды
- SW переключает тёплый и холодный белый; читается как обычная кнопка по фронту с гейтом 50 мс
- Ноль — это FastLED.clear() при живом обработчике: один тик вверх возвращает свет, detachInterrupt теряет щелчки
- 1. Чем энкодер KY-040 лучше потенциометра для ручки яркости?
- 2. Что делает обработчик прерывания в скетче лампы?
- 3. Полный ход яркости от 0 до 255 при шаге 4 и паузе 15 мс занимает...
- 4. Почему при нулевой яркости скетч зовёт FastLED.clear(), а не detachInterrupt?
- 5. Что произойдёт, если позвать FastLED.show() прямо в обработчике на ленте в 100+ пикселей?
Частые вопросы по теме урока
- Чем энкодер KY-040 лучше потенциометра для ручки яркости?
- Энкодер инкрементальный: он шлёт тики с направлением, а не абсолютное значение. Крутить можно бесконечно, а после перезапуска яркость стартует с дефолта скетча. У него нет крайних положений, а щелчки задают шаг — счётчик живёт в скетче
- Что делает обработчик прерывания в скетче лампы?
- В ISR только арифметика цели: плюс-минус восемь и зажим. Рисование и плавный ход живут в loop() — прерывание считает, loop рисует. Меняет цель: target ±8 с зажимом в 0-255, и на этом выходит
- Полный ход яркости от 0 до 255 при шаге 4 и паузе 15 мс занимает...
- 255 / 4 — это 64 шага, 64 × 15 мс = 960 мс, около секунды спокойного хода. Шаг 8 ужмёт дорогу до полусекунды, шаг 2 растянет до двух. Около секунды
- Почему при нулевой яркости скетч зовёт FastLED.clear(), а не detachInterrupt?
- Отцепленное прерывание молчит, и выкрученная вслепую в ноль ручка перестаёт включать лампу. Clear при нуле гасит пиксели, но ручка слушается — тик вверх возвращает свет. Обработчик должен продолжать считать тики: после отцепления первый щелчок на нуле теряется
- Что произойдёт, если позвать FastLED.show() прямо в обработчике на ленте в 100+ пикселей?
- show() толкает по 30 мкс на пиксель: на длинной ленте это миллисекунды внутри прерывания. Всё это время тики и байты Serial теряются — рисовать из ISR нельзя. Миллисекунды блокировки в ISR: сыпется Serial и теряются соседние тики ручки