Serial-плейлист из прошлого урока жил хорошо, пока кабель дотягивался. Гирлянда на балконном окне этот тест провалила: USB-удлинитель на пять метров я купил, но тянуть ноутбук на балкон в январе — удовольствие сомнительное даже для меня. Командовать лентой должна кнопка, приклеенная прямо к раме, — и заодно она решает вторую проблему, о которой Serial не подозревал: гирлянда обязана включаться на том же эффекте, на котором её выключили из розетки, а не сбрасываться в первый.
База у нас уже есть: подключение кнопки без внешнего резистора на INPUT_PULLUP и её инвертированная логика разобраны в уроке про digitalRead, неблокирующие таймеры — в millis() вместо delay(). Здесь добавляются три вещи: различение клика и удержания, честный антидребезг и запись выбора в EEPROM — память, которая переживает выдёргивание вилки.
Кнопка ещё и честнее пульта: у неё один провод и одна функция. Мой первый пульт на ИК-приёмнике прожил до первого show() длиннее двухсот пикселей — приёмник ловил биты, а лента их глушала, и я месяц лечил призраков, пока не понял, что конфликт живёт в самих прерываниях из урока про attachInterrupt. Кнопке на D2 не нужны ни таймеры, ни прерывания: два цифровых состояния, читаемых в loop() между кадрами.
Клик и удержание: два события из одной кнопки
Логика на INPUT_PULLUP инвертирована: кнопка стоит между пином D2 и землёй, пин подтянут к питанию внутри чипа, и нажатие читается как LOW. Дальше вся хитрость — в трёх метках времени. При смене состояния запоминаем millis() и игнорируем любые смены чаще 30 мс — это антидребезг: контакты механической кнопки стучат друг о друга 5–20 миллисекунд, и без фильтра один клик выглядит как серия из трёх–семи. Пока кнопка нажата, каждый проход loop() сравнивает стаж нажатия с порогом 800 мс: дожил — сработало удержание (у меня это переключение яркости 200 ↔ 70, потому что в тёмной комнате двухсотка слепит). Отпустили раньше порога — это клик: режим +1 по кругу. Мелочь, которая экономит нервы: фильтр не сдвигает событие во времени — клик засчитывается в момент отпускания, а не через 30 мс после него, так что ответ ленты ощущается мгновенным.
Порог 800 мс выбран не с потолка: 300–400 мс уже ловятся случайно, когда человек просто целится в кнопку, а 1200 мс ощущаются тугими. Если захотите третье действие — двойной клик ловится теми же метками (два клика короче 350 мс с паузой меньше 300), но двух событий плейлисту хватает с запасом.
Почему фильтр на millis(), а не delay(50)
Классический антидребезг из первых уроков — подождать 50 мс после нажатия. Для счётчика на столе это работает, но в анимационном скетче delay(50) внутри loop() равносилен тому, что вы каждый кадр вручную подвешиваете ленту: кадр 30 мс превращается в 80+, и глаз видит слайдшоу ровно в момент нажатия — самый заметный из всех возможных моментов.
Фильтр на millis() ничего не ждёт: он запоминает момент последней принятой смены состояния и молча игнорирует все смены чаще 30 мс. Дребезг не исчезает — он перестаёт существовать для логики: контакты стучат, скетч видит один фронт. Плата при этом крутит кадры без единой паузы, и нажатие не оставляет на анимации никакого следа. Это тот же принцип «не блокируй loop()», на котором стоят все кадры из урока про millis() вместо delay() — здесь он просто встретился с кнопкой.
| Событие | Как ловим | Действие |
|---|---|---|
| Клик | отпускание раньше 800 мс после нажатия | режим +1 по кругу, печать mode= |
| Удержание | стаж нажатия дошёл до 800 мс, кнопка ещё внизу | яркость 200 ↔ 70, печать brt= |
| Дребезг | смены состояния чаще 30 мс игнорируются | ничего — фильтр молчит |
| Отпускание | смена состояния на «отпущено» | запись режима и яркости в EEPROM |
С памятью интереснее. У ячейки ATmega328 ресурс около 100 000 циклов записи, и это число превращается в ловушку банальнейшим образом: если сохранять режим каждый кадр из loop(), то при тридцати кадрах в секунду лимит выгорает за час. Писать надо по событию — отпускание кнопки случается десятки раз за вечер, и ресурса хватит на годы. И всюду EEPROM.update вместо write: update сравнивает байт с тем, что в ячейке, и физически пишет только при изменении — при повторной записи того же значения ячейка не страдает вовсе. Чтение в setup() дополняется проверкой: свежая или стёртая ячейка отвечает 255, и без проверки ваш плейлист попробует включить эффект номер 255. Валидация спасает и от обратного: когда однажды плейлист вырастет до шести режимов, старые сохранённые «двойки» и «тройки» всё ещё валидны, а вот «семёрки» — нет, и диапазон в if их отсекает.
Мой первый пульт эти проверки пропустил — и через месяц внезапно стал включаться на «эффекте 255», потому что я как-то раз записал яркость поверх режима: сместил ячейку на единицу, а валидацию не дописал. С тех пор правило: у каждой ячейки — имя-константа, диапазон проверен, значение по умолчанию задано явно.
Удержание я отдал яркости не случайно. Режимов у плейлиста четыре, и их круг плохо делится на «короткое и длинное нажатие»; а вот яркость в тёмной комнате — самая частая ручка: двести из 255 хорошо для фото, но слепит семью и жрёт ток пропорционально, о чём в деталях — урок про бюджет тока. Одна длинная секунда под пальцем — и лента вежливо притушена до семидесяти.
Механика удержания опирается на один флаг: holdDone. Без него каждый проход loop() после порога снова и снова перекидывал бы яркость — стаж-то уже больше 800 мс, — а отпускание такого нажатия сработало бы ещё и как клик: режим прыгал бы на каждый длинный пшик. Флаг ставится в момент первого срабатывания и запрещает оба пути сразу: и повторную смену яркости, и клик на отпускании. С этого места правило: у события с длительностью всегда есть флаг «уже отработано», как у письма — штамп.
#include <FastLED.h>#include <EEPROM.h> #define LED_PIN 6#define NUM_LEDS 60#define BTN_PIN 2 CRGB leds[NUM_LEDS];unsigned long lastFrame = 0;unsigned long pressStart = 0;unsigned long lastEdge = 0;int mode = 0;int brt = 200;int hue = 0;int head = 0;int dir = 1;bool wasPressed = false;bool holdDone = false; void drawFrame() { if (mode == 0) { fill_rainbow(leds, NUM_LEDS, hue, 7); hue += 1; } if (mode == 1) { fadeToBlackBy(leds, NUM_LEDS, 60); leds[head] = CHSV(30, 220, 255); head += dir; if (head >= NUM_LEDS - 1) { dir = -1; } if (head <= 0) { dir = 1; } } if (mode == 2) { fadeToBlackBy(leds, NUM_LEDS, 45); if (random8() < 50) { leds[random8(NUM_LEDS)] = CHSV(40, 200, 255); } } if (mode == 3) { FastLED.clear(); }} void setup() { pinMode(BTN_PIN, INPUT_PULLUP); FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS); int savedMode = EEPROM.read(0); int savedBrt = EEPROM.read(1); if (savedMode >= 0 && savedMode <= 3) { mode = savedMode; } if (savedBrt >= 30 && savedBrt <= 255) { brt = savedBrt; } FastLED.setBrightness(brt); Serial.begin(115200); Serial.print("mode="); Serial.print(mode); Serial.print(" brt="); Serial.println(brt);} void loop() { bool pressed = digitalRead(BTN_PIN) == LOW; unsigned long now = millis(); if (pressed != wasPressed && now - lastEdge > 30) { // антидребезг 30 мс lastEdge = now; wasPressed = pressed; if (pressed) { pressStart = now; holdDone = false; } else { if (!holdDone) { // клик mode += 1; if (mode > 3) { mode = 0; } Serial.print("mode="); Serial.println(mode); } EEPROM.update(0, mode); // по событию, не по кадру EEPROM.update(1, brt); } } if (pressed && !holdDone && now - pressStart >= 800) { // удержание holdDone = true; if (brt >= 200) { brt = 70; } else { brt = 200; } FastLED.setBrightness(brt); Serial.print("brt="); Serial.println(brt); } if (millis() - lastFrame >= 30) { lastFrame = millis(); drawFrame(); FastLED.show(); }}В симуляторе тот же пульт: кнопка на макетке сидит на D2 с INPUT_PULLUP, эффекты те же четыре, что в Serial-версии, — переезжает только управление, вся логика рисования на месте.
Две честные оговорки про симулятор. Первая: виртуальная кнопка даёт чистый фронт без дребезга, так что фильтр 30 мс здесь ничего не отсекает — оставлять его в коде всё равно обязательно, потому что на живой плате без него счётчик кликов растёт по три–семь за нажатие, а путь через attachInterrupt эту болячку только переносит в другое место. Вторая: EEPROM в симуляторе пуст при каждом запуске скетча и сбрасывается при перезапуске, тогда как на плате хранит выбор годами — проверяется это просто: записанный в Serial mode= и brt= после «выдёргивания вилки» (перезапуска) должны вернуться в стартовой строке. Попробуйте нажать и держать ровно на границе — 700–900 миллисекунд: порог ощущается телом, и вы поймёте, почему я не ставлю его ниже; а потом держите дольше секунды и убедитесь, что brt= не мелькает повторно — флаг уже отработал своё.
Что докрутить, когда пульт надоест: длинное удержание на 2,5 секунды — стирание и перезапуск плейлиста с эффекта 0; вторая кнопка на D3 с листанием в обратную сторону, чтобы к радуге не крутиться через круг; хранение в EEPROM не только режима, но и последней яркости перед ночным режимом. Токовая сторона яркости — с какими числами 200 и 70 приходится мириться блоку питания — уже разобрана в уроке про бюджет тока, а сам пульт можно развить в полноценное меню на той же кнопке.
- 1. Кнопка стоит между D2 и GND, pinMode(2, INPUT_PULLUP). Что вернёт digitalRead(2) при нажатии?
- 2. Счётчик кликов растёт по 3–7 за одно нажатие. В чём причина?
- 3. Чем EEPROM.update(0, mode) лучше EEPROM.write(0, mode) в скетче с кнопкой?
- 4. Как отличить удержание от клика в неблокирующем loop()?
- 5. Гирлянда хранит режим в EEPROM. Когда писать?
- INPUT_PULLUP и кнопка между пином и GND: нажато — LOW, логика инвертированная.
- Дребезг 5–20 мс лечится фильтром на millis(): смены состояния чаще 30 мс игнорируются.
- Клик — отпускание раньше порога 800 мс; удержание — стаж нажатия дошёл до порога, пока кнопка внизу.
- EEPROM.update по событию, а не write каждый кадр: ресурс ячейки — около 100 000 записей.
- Чтение в setup() с валидацией: стёртая ячейка отвечает 255 и не должна ломать плейлист.
Частые вопросы по теме урока
- Кнопка стоит между D2 и GND, pinMode(2, INPUT_PULLUP). Что вернёт digitalRead(2) при нажатии?
- INPUT_PULLUP подтягивает пин к питанию внутри чипа, кнопка прижимает его к земле: нажато — LOW. Логика инвертированная, и это надо помнить во всех if. LOW — 0
- Счётчик кликов растёт по 3–7 за одно нажатие. В чём причина?
- Механические контакты дребезжат 5–20 мс — Arduino видит серию нажатий. Лечится фильтром: смены состояния чаще 30 мс игнорируются. Кнопка дребезжит: контакты стучат друг о друга 5–20 мс
- Чем EEPROM.update(0, mode) лучше EEPROM.write(0, mode) в скетче с кнопкой?
- update сначала сравнивает байт с содержимым ячейки и физически пишет только при отличии. write дерёт ресурс при каждом вызове, даже если значение то же. Пишет байт только когда значение изменилось — бережёт ресурс ячейки
- Как отличить удержание от клика в неблокирующем loop()?
- При нажатии запоминается pressStart = millis(); пока кнопка внизу, стаж сравнивается с порогом — дожил, значит удержание; отпустил раньше — клик. Запомнить millis() нажатия и сравнивать стаж с порогом 800 мс
- Гирлянда хранит режим в EEPROM. Когда писать?
- Запись по событию случается десятки раз за вечер и не трогает ресурс; каждый кадр при 30 FPS выжигает 100 000 циклов примерно за час. По событию: при отпускании кнопки после клика или удержания