Помодоро-таймер жил у меня в телефоне ровно до первого жёсткого дедлайна. Схема проваливалась каждый раз одинаково: ставлю таймер на 25 минут, кладу телефон рядом — и минут через десять я уже читаю ленту в том же телефоне. Вторая проблема вылезла на видеозвонках: экран гаснет, таймер никто не видит, и коллеги не понимают, почему я исчезаю каждые двадцать пять минут. Лечение оказалось железным: отдельная коробочка с экраном, одной кнопкой и зуммером, которая висит на мониторе и показывает остаток так, чтобы он читался краем глаза. Телефон с тех пор уходит в сумку.
Метод знакомый: 25 минут работы, 5 отдыха, а после четырёх завершённых циклов — длинный перерыв: в оригинале метода это 15-30 минут, в самодельных проектах чаще ставят 20. Приглядываясь к чужим помодоро-сборкам на Arduino Project Hub и Hackster, я заметил одну и ту же анатомию: три режима, звук на границе отрезков и световая индикация, чтобы отличать работу от отдыха, не читая экран. Всё это здесь будет: зуммер переходит тонами, светодиод горит только в работе, счётчик циклов сидит в шапке.
Фундамент уже насыпан: полоса прогресса разобрана до пикселя в уроке про бар и слайдер, петля кадра без мерцания — в уроке про анимацию, а кнопка и её фронт — в основах. Новое тут одно, но оно главное — машина состояний на millis: режимы, тик раз в секунду, честная пауза и ни одного delay. Именно на такой задаче схема из урока про millis перестаёт быть абстракцией: таймер, который уползает на пару секунд за день, — не теория, а моя первая версия, если её не лечить.
Экран: три зоны и остаток крупно
Экран я делю на три зоны, как в уроке про текст и курсор: шапка в одну строку, крупные цифры остатка, полоса прогресса внизу. Шрифт размера 2 занимает 12×16 пикселей — на весь экран это четыре строки по десять символов, и остаток в формате 24:59 влезает впритык: пять знаков со сдвигом на двадцатый пиксель. Размер 3 уже не подходит: восемь символов в строке, минуты с двоеточием и ведущим нулём не помещаются, так что потолок крупности именно здесь.
Бар — тот же дуэт drawRect и fillRect с внутренними 126 пикселями: fill = proc * 126 / 100, умножение до деления, иначе на 74 процентах получите нулевую заливку — эту граблю я уже разбирал в уроке про бар. Проценты печатаю мелким шрифтом под баром: полоса отвечает на вопрос «много ли прошло», число — «ровно сколько», вместе они не спорят. И ещё мелочь, которая отличает прибор от примера: ведущий ноль в минутах и секундах. Без него цифры прыгают по ширине, глаз цепляется за каждое перескакивание, и через неделю это начнёт бесить; две проверки перед печатью снимают проблему насовсем.
Темп обновления — раз в 500 мс по millis. Кадр на 100 кГц стоит около 92 мс шины, значит, при таком темпе I2C занят меньше пятой части, и зуммер с кнопкой остаются в приоритете. Гнать экран чаще незачем: цифры меняются раз в секунду, и ускорение не сделает их плавнее. Мой практический тест на голодание шины — часы на тех же проводах: когда я гонял экран на 100 мс, часы начинали отставать; при 500 мс запас пятикратный.
| Режим | Длительность | Светодиод D12 | Звук на входе в режим |
|---|---|---|---|
| Работа | 25 минут | горит | низкий тон 784 Гц, 300 мс |
| Отдых | 5 минут | погашен | высокий тон 1568 Гц, 300 мс |
| Большой перерыв | 20 минут, каждый 4-й цикл | погашен | высокий тон 1568 Гц, 600 мс |
| Пауза по кнопке | пока не нажмут снова | погашен | короткий тик 988 Гц, 80 мс |
#include <Wire.h>#include <Adafruit_GFX.h>#include <Adafruit_SSD1306.h> Adafruit_SSD1306 display(128, 64, &Wire, -1); const unsigned long MINUTA = 60000UL; // тест на столе: 1000ULconst unsigned long DOLG_RABOTA = 25UL * MINUTA;const unsigned long DOLG_OTDYH = 5UL * MINUTA;const unsigned long DOLG_BOLSHOJ = 20UL * MINUTA; int sost = 1; // 1 работа, 2 отдых, 3 большой перерывunsigned long ostatok = DOLG_RABOTA;unsigned long lastTick = 0;unsigned long lastKadr = 0;int cikl = 0; // завершённые рабочие отрезкиbool rabotaet = false;bool bPrev = false; void setup() { display.begin(SSD1306_SWITCHCAPVCC, 0x3C); display.setTextColor(SSD1306_WHITE); pinMode(2, INPUT_PULLUP); // старт и пауза pinMode(12, OUTPUT); // свет: горит только в работе pinMode(8, OUTPUT); // зуммер переходов Serial.begin(9600);} long dolgSeg() { // длительность текущего отрезка if (sost == 1) return DOLG_RABOTA; if (sost == 2) return DOLG_OTDYH; return DOLG_BOLSHOJ;} void konecOtrezka() { if (sost == 1) { cikl = cikl + 1; if (cikl % 4 == 0) { // каждый четвёртый — большой перерыв sost = 3; ostatok = DOLG_BOLSHOJ; tone(8, 1568, 600); } else { sost = 2; ostatok = DOLG_OTDYH; tone(8, 1568, 300); } Serial.print("cikl="); Serial.println(cikl); } else { sost = 1; ostatok = DOLG_RABOTA; tone(8, 784, 300); // низкий тон — пора за работу }} void ekran() { display.clearDisplay(); display.setTextSize(1); display.setCursor(0, 0); if (!rabotaet) display.print("PAUZA "); else if (sost == 1) display.print("RABOTA "); else display.print("OTDYH "); display.print("cikl="); display.println(cikl); display.setTextSize(2); // крупные цифры остатка display.setCursor(20, 16); long sek = ostatok / 1000; if (sek / 60 < 10) display.print("0"); display.print(sek / 60); display.print(":"); if (sek % 60 < 10) display.print("0"); display.println(sek % 60); display.drawRect(0, 40, 128, 12, SSD1306_WHITE); long proc = ((dolgSeg() - ostatok) * 100) / dolgSeg(); int fill = proc * 126 / 100; if (fill > 0) display.fillRect(1, 41, fill, 10, SSD1306_WHITE); display.setTextSize(1); display.setCursor(0, 56); display.print(proc); display.println(" %"); display.display();} void loop() { bool b = (digitalRead(2) == LOW); if (b && !bPrev) { // фронт: одно нажатие — одно событие rabotaet = !rabotaet; lastTick = millis(); tone(8, 988, 80); } bPrev = b; if (rabotaet && sost == 1) digitalWrite(12, HIGH); else digitalWrite(12, LOW); if (rabotaet && millis() - lastTick >= 1000) { lastTick = lastTick + 1000; // догоняем расписание — без дрейфа if (ostatok > 1000) { ostatok = ostatok - 1000; } else { ostatok = 0; konecOtrezka(); } } if (millis() - lastKadr >= 500) { // экран: два кадра в секунду lastKadr = millis(); ekran(); }}Кнопка по фронту, циклы и большой перерыв
Кнопка старт/пауза читается фронтом: bool b = (digitalRead(2) == LOW) и сравнение с прошлым чтением. По уровню делать нельзя: loop пролетает десятки тысяч раз в секунду, и удержание кнопки переключало бы паузу на каждом проходе. Фронт ловит одно нажатие как одно событие. Дребезг в этой задаче почти безвреден — руками тумблер туда-сюда за десять миллисекунд не сделать, — но кнопка с длинными проводами иногда подкидывает лишний фронт; если поймали такое, лечение в уроке про дребезг.
Звуковые переходы я развёл по высоте, чтобы различать их не глядя на экран: к отдыху — высокий тон 1568 Гц, обратно к работе — низкий 784 Гц, подтверждение кнопки — короткий тик 80 мс. Механику tone и noTone я собирал в уроке про пьезозуммер; здесь важно другое — звуковой таймер в этом скетче ничем больше не занят, конфликтов с ШИМ нет, и три разных сигнала живут в трёх строках.
Счётчик циклов прибавляет единицу в момент, когда кончилась работа: переход работа→отдых и есть завершённый помидор. Каждый четвёртый отрезок отдыха удлиняется до большого перерыва: cikl % 4 == 0 — и вместо пяти минут двадцать. Сбрасывать счётчик после большого перерыва или копить дальше — вопрос вкуса; я коплю за день и обнуляю плату ночной перезагрузкой. Для проверки всей логики на столе в скетче есть ручка масштаба: MINUTA = 1000UL сжимает минуту в секунду, полный круг из 25 и 5 занимает полминуты, все три тона слышны за одну проверку. На плате возвращаете 60000UL и больше ничего не трогаете — логика не зависит от того, сколько миллисекунд в вашей минуте.
Про потолок millis стоит сказать прямо, потому что его пугают зря: счётчик переполняется через 49,7 суток, но скетч не хранит millis() в переменной на месяц — он работает только с разностями, а разности переполнения не боятся: беззнаковое вычитание заворачивается честно, и разность через границу равна разности без неё. Пауза на неделю в этой арифметике тоже не ломается: остаток лежит числом, тик сравнивает два unsigned long, и на этом вся хронология прибора закончилась — дата ему не нужна, минуты и секунды он отщипывает сам.
Два апгрейда, которые я прикидывал для рабочей версии. Первый — счётчик циклов в EEPROM: пара байт, и память переживает выдёргивание питания — запись при каждом переходе, чтение в setup. Второй — когда захочется показывать ещё и время суток между помидорами, крупные цифры от настоящих часов уже разобраны в уроке про RTC: тот же экран, те же размеры шрифта, другая цифра. Мой рабочий экземпляр живёт без обоих: обнуление ночью делает своё дело.
- Три режима в одной переменной sost: работа 25 минут, отдых 5, после каждого четвёртого цикла — большой перерыв 20
- Остаток времени — число; тик 1000 мс по millis с lastTick = lastTick + 1000 — без дрейфа; пауза перестаёт тикать, а не сбрасывает
- Кнопка старт/пауза читается фронтом b/bPrev — удержание не щёлкает режимами на каждом проходе loop
- Переходы разведены высотой тона: 1568 Гц к отдыху, 784 Гц к работе, тик 988 Гц на подтверждении кнопки
- Бар: drawRect рамка + fillRect заливка, fill = proc * 126 / 100; экран обновляется раз в 500 мс — шина занята меньше чем на пятую часть
- 1. Что происходит после четвёртого завершённого рабочего отрезка?
- 2. Почему тик пишется как lastTick = lastTick + 1000, а не lastTick = millis()?
- 3. Кнопку старт/пауза читают фронтом, потому что...
- 4. Рамка бара 128 пикселей. Сколько пикселей займёт заливка на 50 процентах?
- 5. Каким тоном встречаем конец отдыха и возврат к работе?
Домашнее задание простое и долгое: соберите на плате с MINUTA = 1000UL, прогоните все три тона и большой перерыв, затем верните 60000UL и оставьте коробочку на столе на день. Вечером сверьте с телефонным таймером: если расхождение в пределах секунды — тик выписан правильно. Из апгрейдов по вкусу — мигающий светодиод в отдыхе по той же схеме millis и счётчик циклов в EEPROM, чтобы переживал выключение.
А следующий урок снова про сигналы: пока здесь таймер отсчитывал минуты, мы научимся заглядывать в миллисекунды. Мини-осциллограф на том же экране покажет фронт ручки потенциометра и дребезг кнопки — то, что фильтры прячут, а глаза должны видеть.
Частые вопросы по теме урока
- Что происходит после четвёртого завершённого рабочего отрезка?
- Каждый четвёртый переход работа→отдых открывает большой перерыв: в коде это cikl % 4 == 0, и вместо 5 минут отводится 20. Отдых удлиняется — наступает большой перерыв
- Почему тик пишется как lastTick = lastTick + 1000, а не lastTick = millis()?
- Перезапись millis() делает период равным 1000 мс плюс время на рисование и Serial. За день набегают секунды; сложение с шагом держит секунды честными. Тик догоняет расписание: вариант с millis() накапливает дрейф от медленного loop
- Кнопку старт/пауза читают фронтом, потому что...
- loop пролетает десятки тысяч раз в секунду: уровень дал бы сотни переключений за одно удержание. Фронт — одно нажатие, одно событие. По уровню режим переключался бы на каждом проходе loop при удержании
- Рамка бара 128 пикселей. Сколько пикселей займёт заливка на 50 процентах?
- Внутренняя ширина шкалы — на два пикселя меньше рамки: 126. Половина — 63 пикселя, по пикселю запаса на каждую стенку. 63 — fill = 50 * 126 / 100
- Каким тоном встречаем конец отдыха и возврат к работе?
- Высота тона различает переходы без взгляда на экран: высокий — к отдыху, низкий — к работе. Одновременно tone на Uno не умеет играть две ноты. Низким 784 Гц — пора за работу