На кухне у меня года два жил таймер-кубик с цифрами: заварка, яйца всмятку, кускус. Полезен он ровно до момента, когда вы отошли: цифры мелкие, читаются только вплотную, и вопрос «сколько ещё?» каждый раз превращался в поход к кубику. Полоса из WS2812 под полкой решает это иначе: время видно через всю кухню боковым зрением — сколько полосы осталось, столько и минут.
Считаем честно: 60 секунд на 30 пикселей — по две секунды на пиксель, а 30 пикселей — это ровно метр самой ходовой ленты с плотностью 30 на метр. Последние 10 секунд — пять пикселей — мигают красным и пищат раз в секунду: их видно даже спиной к полке. Финал — три белые вспышки и тройной бип. Кнопка на D2 стартует отсчёт заново в любой момент, и всё это — на millis(), без единого delay().
Каркас millis-цикла для ленты уже собран в уроке про эффекты без delay, а разбор millis вместо delay объясняет, почему таймеры на вычитании не ломаются даже на 49,7 сутках переполнения. Здесь добавляется новая вещь: не эффект, который рисуется сам, а счёт — состояние, которое расходуется со временем.
Геометрия: секунды в пиксели
Вся математика полосы — одна пропорция: горящих пикселей столько, сколько времени осталось, lit = left * NUM_LEDS / TOTAL_MS. При 60 секундах и 30 пикселях пиксель живёт две секунды — глаз ловит каждый шаг, и счёт ощущается телом. Хотите таймер на десять минут — та же формула даст 20 секунд на пиксель, и полоса задышит лениво: движение меньше пикселя за двадцать секунд глаз уже не читает. Моё правило с границей применимости: полоса хороша до пяти минут, дальше — семисегментный индикатор или экран.
Цвет несёт то же, что длина: оттенок ползёт по колесу от зелёного (96) к красному (0) пропорционально остатку — hue = left * 96 / TOTAL_MS. К последней десятке полоса уже жёлто-красная, и мигание читается как «всё, конец» ещё до звука. Таять пиксели могут и мягче — вместо жёсткого гашения fadeToBlackBy на каждом кадре, механику затухания разобрали в отдельном уроке; мне для кухни жёсткие ступени понравились больше: счёт читается точнее, а не «примерно».
#include <FastLED.h> #define LED_PIN 6#define NUM_LEDS 30#define BTN 2#define BUZZER 8 const unsigned long TOTAL_MS = 60000UL; // 60 секунд: пиксель живёт 2 секундыconst unsigned long ALARM_MS = 10000UL; // последние 10 секунд мигают и пищат CRGB leds[NUM_LEDS];unsigned long startedAt = 0;unsigned long lastBeep = 0;unsigned long lastLog = 0;unsigned long flashStart = 0;int lastFlash = -1;int state = 0; // 0 - ждёт, 1 - идёт, 2 - финал void setup() { Serial.begin(9600); FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS); FastLED.setBrightness(200); pinMode(BTN, INPUT_PULLUP); pinMode(BUZZER, OUTPUT); fill_solid(leds, NUM_LEDS, CRGB(40, 40, 40)); // «готов»: тусклая полоса FastLED.show();} void loop() { static int lastBtn = HIGH; static unsigned long lastBtnChange = 0; int btn = digitalRead(BTN); if (btn != lastBtn && millis() - lastBtnChange > 50) { lastBtnChange = millis(); lastBtn = btn; if (btn == LOW) { // рестарт в любом состоянии startedAt = millis(); lastBeep = millis(); lastLog = millis(); lastFlash = -1; state = 1; noTone(BUZZER); } } unsigned long elapsed = millis() - startedAt; if (state == 1) { unsigned long left = 0; if (elapsed < TOTAL_MS) { left = TOTAL_MS - elapsed; } int lit = left * NUM_LEDS / TOTAL_MS; FastLED.clear(); for (int i = 0; i < lit; i++) { if (left > ALARM_MS) { leds[i] = CHSV(left * 96 / TOTAL_MS, 255, 255); // зелёный тает в красный } else if ((millis() / 250) % 2 == 0) { leds[i] = CRGB(255, 0, 0); // мигание последних 10 с } } FastLED.show(); if (millis() - lastLog >= 4000) { lastLog = millis(); Serial.print("left="); Serial.print(left / 1000); Serial.print("s lit="); Serial.println(lit); } if (left > 0 && left <= ALARM_MS && millis() - lastBeep >= 1000) { lastBeep = millis(); tone(BUZZER, 1000, 80); // бип раз в секунду на финише } if (left == 0) { state = 2; flashStart = millis(); } } if (state == 2) { // три белые вспышки и тройной бип unsigned long t = millis() - flashStart; int flash = t / 400; if (flash != lastFlash) { lastFlash = flash; if (flash < 3) { tone(BUZZER, 1400, 150); } } FastLED.clear(); if (t < 1200UL && (t / 200) % 2 == 0) { fill_solid(leds, NUM_LEDS, CRGB(255, 255, 255)); } FastLED.show(); if (t >= 1200UL) { noTone(BUZZER); state = 0; } }}Разбор: три состояния и ни одного delay
Три состояния — ждёт, идёт, финал — живут в переменной state, и каждый проход loop() отрисовывает текущее с нуля: clear, покраска, show. Кнопка работает по фронту с гейтом 50 мс — классический антидребезг из урока про дребезг кнопки — и в любом состоянии перезапускает счёт: нажали посреди отсчёта, полоса развернулась заново. Именно фронт, а не уровень: иначе удерживаемая кнопка перезапускала бы счёт каждые 50 миллисекунд, и таймер стоял бы на месте, пока держите палец.
Звук заведён на тех же таймерах: раз в секунду на последних десяти — tone(BUZZER, 1000, 80) с гейтом lastBeep; на финале — три бипа по гейту lastFlash, вспышек три, бип на каждую. Механику tone и noTone с пассивным излучателем я разбирал в уроке про подключение зуммера; здесь важно другое: бип на миллис-гейте — это ровно тот же приём, что и кадр на таймере, событие раз в N миллисекунд без блокировки.
И про честность мигания: деление millis() / 250 % 2 режет время на окна по 250 мс и гасит пиксели в чётных — лента мигает ровно два раза в секунду независимо от длины прохода. Проверьте в симуляторе на скорости ×100: ритм остаётся ровным, потому что привязан к настоящим миллисекундам, а не к числу проходов loop().
| Этап | Остаток времени | Полоса | Звук |
|---|---|---|---|
| Старт | 60-50 секунд | вся горит, зелёная | тишь |
| Середина | около 30 секунд | половина, оттенок жёлтый | тишь |
| Финальные 10 секунд | 10-0 секунд | 5 пикселей мигают красным | бип раз в секунду |
| Финал | 0 | три белые вспышки по 200 мс | тройной бип |
Масштабирование — та же формула с новыми числами, и у неё есть окно комфорта. На 60 секундах и 30 пикселях шаг — две секунды: глаз ловит каждый переход. Помодоро на 25 минут растянет шаг до 50 секунд: полоса почти стоит, и я либо режу её блоками по пять минут с пикселем-разделителем, либо честно беру экран — для длинных интервалов полоса не инструмент. Практический коридор: от одной до пяти секунд на пиксель; дальше движение перестаёт читаться, короче секунды — полоса мелькает как стробоскоп.
Второй сценарий, который вы соберёте за десять минут, — «горячие практики»: зубная щётка на две минуты, проявка плёнки, детский вопрос «ещё пять минут». У детей полоса работает лучше слов: мигающие красные пиксели видно из другого конца комнаты, и «пять минут» превращаются из спора в измеримую полосу. Для щётки я делал короткую версию: 120 секунд на тех же шестнадцати пикселях — по 7,5 секунды на пиксель, мигание на последних десяти.
Число секунд после старта можно присылать и из монитора: Serial.parseInt отдаст его, а скетч пересчитает TOTAL_MS на ходу — механика parseInt разобрана в уроке про числа из порта. Так кухонный таймер становится настраиваемым без перешивки: 60 для чая, 180 для яиц, 600 для теста — и все три живут в одном скетче.
- 1. Сколько времени живёт один пиксель при отсчёте 60 секунд на 30 пикселях?
- 2. Почему остаток времени считают вычитанием millis() - startedAt, а не уменьшением переменной в цикле?
- 3. Что происходит на последних десяти секундах отсчёта?
- 4. Что сделает нажатие кнопки посреди отсчёта?
- 5. Почему вспышки финала отмеряются делением millis() на окна по 200 мс, а не цепочкой delay?
- lit = left * NUM_LEDS / TOTAL_MS — вся математика полосы; метр ленты 30 пикселей = минутный таймер с шагом две секунды
- Три состояния на millis: ждёт, идёт, финал; кнопка по фронту рестартует в любой момент
- Последние 10 секунд — мигание окнами по 250 мс и бип раз в секунду; финал — три белые вспышки и тройной бип
- Оттенок ползёт от зелёного 96 к красному 0 пропорционально остатку: длина и цвет говорят одно и то же
- Комфортный шаг — 1-5 секунд на пиксель; интервалы длиннее пяти минут полоса честно перестаёт читать
Частые вопросы по теме урока
- Сколько времени живёт один пиксель при отсчёте 60 секунд на 30 пикселях?
- Общая длительность делится на число пикселей: 60000 мс на 30 — по 2000 мс. Отсюда и выбор ленты: метр с плотностью 30 на метр — это как раз минутный таймер. 2 секунды
- Почему остаток времени считают вычитанием millis() - startedAt, а не уменьшением переменной в цикле?
- Счётчик-переменная ползёт с погрешностью каждого прохода, а millis() — показания настоящих часов платы: сколько бы loop ни занял, остаток остаётся честным. Вычитание не зависит от длины прохода loop и не накапливает погрешность
- Что происходит на последних десяти секундах отсчёта?
- Условие left <= ALARM_MS включает мигание с окнами по 250 мс и бип на гейте 1000 мс: конец виден и слышен ещё до финальных вспышек. Оставшиеся пиксели мигают красным, раз в секунду бипит зуммер
- Что сделает нажатие кнопки посреди отсчёта?
- Фронт кнопки перезаписывает startedAt на текущий millis() в любом состоянии: рестарт — самая ходовая кнопка кухонного таймера, недосчитал — жми заново. Перезапустит отсчёт с полной полосы
- Почему вспышки финала отмеряются делением millis() на окна по 200 мс, а не цепочкой delay?
- Окна по millis держат ритм ровным при любой длине прохода, и кнопка продолжает работать — на delay-версии ломается и то и другое одновременно. Так ритм вспышек точен, а loop остаётся отзывчивым к кнопке