Прибор, который после подачи питания полторы секунды молчит, у пользователя ассоциируется ровно одним способом: завис. Настоящая инициализация действительно занимает время — датчики прогреваются и заводятся по шине, карта ищется, сеть поднимается, — но молчаливое ожидание и ожидание со спиннером ощущаются как две разные планки качества. На моём гаражном приборе загрузочный экран появился после того, как отец дважды выдернул питание «за зависание» на этапе, который честно работал три секунды.
Здесь собираем весь приборный акт оживания: спиннер из четырёх кадров, прогресс-бар с процентами, привязанный к реальным шагам, и пасхалка — секретный экран после пяти нажатий кнопки. Механика кадра уже разобрана в уроке про анимацию без мерцания: один display() на кадр, всё остальное — в буфер. Новое — кадры по счётчику времени и счётчик серии нажатий с окном тишины.
Пасхалка — не баловство, а культура приборов: сервисные меню и скрытые экраны в настоящей технике живут за последовательностями нажатий, и на самодельном устройстве такой экран выручает — версию прошивки, имя автора, счётчик часов наработки показывать больше некому. Дети нашли мою первую пасхалку за неделю; счётчик нажатий после этого пришлось сделать честным.
Спиннер: четыре кадра по millis
Классический спиннер — четыре положения: вертикальная черта, слэш, горизонтальная черта, обратный слэш. Кадр удобно считать прямо из времени: kad = (millis() / 150) % 4 — номер здесь функция времени, счётчик не нужен, и анимация не теряет кадр, даже если loop чем-то занят. Период 120-150 мс я считаю рабочим: медленнее — спиннер выглядит заевшим, быстрее — глаз не успевает, и на шине 100 кГц кадр за 92 мс начинает толкаться с соседями.
На плате тот же круг можно нарисовать красиво: у Adafruit GFX нет готовой дуги, но есть drawCircleHelper(x, y, r, cornername, цвет) — четвёртый аргумент это биты-квадранты, и вызов drawCircleHelper(x, y, 10, 1 << kad, SSD1306_WHITE) рисует по очереди четверть окружности за кадром. Друзья-примитивы — в уроке про линии и фигуры; один бит на кадр — и текстовый спиннер превращается в круглый без единого нового массива.
Светодиод на D12 я подмигиваю в такт: digitalWrite(12, kad % 2) — одна строка, а прибор оживает сразу в двух местах, экране и коробке. Это тот же закон экономии, что во всём разделе: пока кадры идут по millis, любая мелочь в loop остаётся живой, и delay здесь не нужен ни в каком виде.
Прогресс — место, где я сам наступал на грабли. Первая версия тикала процентами от таймера: бар добегал до ста за секунду и весело стоял ещё две, пока инициализация реально шла. Экран перегоняет работу — это хуже, чем медленный бар, потому что ложь видна. Честный процент считается из завершённых шагов: proc = shag * 100 / VSEGO, где шаг — это begin() вашего датчика, поиск карты, подключение сети. Загрузка без шагов не должна рисовать бар вообще — ей хватает спиннера.
Полоса — та же пара drawRect и fillRect с внутренними 126 пикселями, что в уроке про бар, с одной поправкой на контекст: здесь заливка прыгает на четверть или пятую часть сразу, и я добавляю к ней проценты и подпись текущего шага мелким шрифтом. Прыжок перестаёт раздражать, когда видишь его причину: SENSOR... OK, NET... OK — шаги читаются, бар догоняет.
| Анимация | Как собирается | Период | Когда ставить |
|---|---|---|---|
| Спиннер ASCII | 4 кадра | / - \\ из millis % 4 | 120-150 мс | шаги неизвестны или короче 0,5 с |
| Круговой спиннер | drawCircleHelper, квадрант 1 << kad | 120-150 мс | то же, но хочется круглого |
| Прогресс с процентами | шаги × 100 / всего + drawRect/fillRect | на смене шага | шаги известны и длиннее секунды |
| Пасхалка | счётчик нажатий + окно тишины | появление на 3 с | сервисный экран, авторская подпись |
#include <Wire.h>#include <Adafruit_GFX.h>#include <Adafruit_SSD1306.h> Adafruit_SSD1306 display(128, 64, &Wire, -1); const char* KADRY = "|/-\\"; // четыре положения спиннераconst char* IMENA[4] = {"SENSOR", "NET", "CARD", "RTC"};const int VSEGO = 4; // шагов инициализации int shag = 0;int kad = 0;unsigned long lastSpin = 0;unsigned long lastShag = 0;unsigned long lastKnop = 0;int nazhatij = 0;bool sekret = false;unsigned long sekretDo = 0;bool bPrev = false; void setup() { display.begin(SSD1306_SWITCHCAPVCC, 0x3C); display.setTextColor(SSD1306_WHITE); pinMode(2, INPUT_PULLUP); pinMode(12, OUTPUT); Serial.begin(9600); // здесь настоящий прибор позвал бы begin() датчиков — // каждый шаг вместо имитации по таймеру} void loop() { unsigned long now = millis(); if (shag < VSEGO) { // фаза загрузки if (now - lastSpin >= 150) { // спиннер: кадр каждые 150 мс lastSpin = now; kad = (kad + 1) % 4; digitalWrite(12, kad % 2); // светодиод в такт спиннеру } if (now - lastShag >= 800) { // шаг завершён lastShag = now; shag = shag + 1; tone(8, 1200, 40); Serial.print("shag "); Serial.println(shag); } } bool b = (digitalRead(2) == LOW); if (b && !bPrev) { // фронт: считаем серию нажатий if (now - lastKnop > 2000) nazhatij = 0; // окно тишины — серия прервалась lastKnop = now; nazhatij = nazhatij + 1; if (nazhatij == 5) { sekret = true; sekretDo = now + 3000; tone(8, 1568, 200); Serial.println("SEKRET"); } } bPrev = b; if (sekret && now > sekretDo) sekret = false; // пасхалка уходит сама display.clearDisplay(); if (sekret) { display.setTextSize(2); display.setCursor(8, 24); display.println("* SECRET *"); } else if (shag < VSEGO) { display.setTextSize(1); display.setCursor(0, 0); display.print("Zagruzka "); display.println(KADRY[kad]); int proc = shag * 100 / VSEGO; // процент из завершённых шагов display.setCursor(0, 18); display.print(proc); display.println(" %"); display.drawRect(0, 32, 128, 12, SSD1306_WHITE); int fill = proc * 126 / 100; if (fill > 0) display.fillRect(1, 33, fill, 10, SSD1306_WHITE); display.setCursor(0, 52); display.print(IMENA[shag]); display.println("..."); } else { display.setTextSize(2); display.setCursor(28, 24); display.println("READY"); } display.display();}Пасхалка: пять нажатий и окно тишины
Счётчик серии — это фронт плюс окно неактивности. Каждое нажатие по фронту прибавляет единицу; если между нажатиями прошло больше двух секунд, серия обрывается и счёт обнуляется. Без окна счёт копился бы весь день: двадцать случайных касаний за смену — и экран вылезает сам, посреди работы. С окном секрет открывается только намеренной серией: пять нажатий подряд, быстрее двух секунд каждое.
На плате дребезг подкинет два-три счёта за одно нажатие — в этой задаче я его не лечу, а учитываю: порог сдвигается, и загляните всё же в урок про дребезг, если серия будет открываться от одного тапа. Сам экран — три секунды по метке sekretDo и возврат: пасхалка, которая не уходит сама, перестаёт быть пасхалкой и становится багом интерфейса. Картинку можно усложнить до логотипа из массива байтов — drawBitmap встаёт сюда без единой новой переменной состояния.
Наполнение секретного экрана у каждого своё. У меня в гаражном приборе там версия прошивки и счётчик часов наработки, набранный из millis, — ремонтнику это экономит первые вопросы. В детской комнате прибор показывает смайл из четырёх символов, и это лучшая инвестиция в интерес к технике, которую я знаю: дети теперь по очереди открывают его каждое утро.
С таймингами работает простая линейка, вывезенная из мобильных интерфейсов: что короче полутора секунд — не анимируйте вовсе, человек не успел засомневаться; полторы-три секунды — спиннер; дольше трёх — уже прогресс с процентами, потому что обещание «скоро» без числа перестаёт быть правдой. Мои приборы попадали во все три полосы: датчик на I2C заводится за 0,2 с, и спиннер там лишний; карта ищется около трёх секунд — спиннер в самый раз; сеть с ретраями тянет пять-семь, и там без честного процента пользователь уже тянется к питанию.
И про финал: экран READY — не декорация, а граница ответственности загрузки. Пока вертится спиннер, кнопка не обязана работать; с момента READY прибор обещает отзываться на всё. Если после READY что-то не откликается — это уже не загрузка, а грабля основного цикла, и искать её нужно в loop: чаще всего это чужой delay, въехавший из примера и поселившийся навсегда.
Звук в загрузке я оставляю тихим и редким: тик на завершённом шаге по 40 мс — и всё. Спиннер, который пищит каждые 150 мс, за три секунды превращается в сверчок, и пользователь выдёргивает питание не из-за зависания, а из-за шума. Звук должен подтверждать этапы, а не сопровождать кадры — это то же правило, что и в помодоро-таймере: тон на границе состояний, тишина внутри.
- Кадр спиннера — функция времени: (millis() / 150) % 4; на плате четверть окружности за кадр рисует drawCircleHelper(x, y, r, 1 << kad, ...)
- Один display() на кадр и кадры по millis — тот же закон, что в любой анимации OLED; delay в загрузке замораживает и кнопку, и звук
- Честный процент — из завершённых шагов: proc = shag * 100 / VSEGO; таймерный бар перегоняет работу и врёт дважды
- Пасхалка: пять нажатий по фронту с окном тишины 2 с; экран живёт 3 с и возвращается сам
- Светодиод на D12 мигает в такт спиннеру — digitalWrite(12, kad % 2), синхронизация на одну строку
- 1. Самая короткая версия номера кадра спиннера при периоде 150 мс?
- 2. Честный процент прогресса при пошаговой инициализации считается...
- 3. Зачем в счётчике пасхалки окно тишины 2 секунды?
- 4. Как на плате нарисовать круглый спиннер без массивов кадров?
- 5. Спиннер идёт кадрами по millis и с одним display() на кадр. Что это даёт?
Домашнее задание: в симуляторе откройте пасхалку серией из пяти нажатий, затем попробуйте сорвать серию — четыре нажатия и пауза в три секунды; счётчик должен обнулиться. На плате замените имитацию шагов настоящими begin() ваших датчиков и посмотрите на реальный процент: у моей метеостанции шаг карты занимал 1,2 с из трёх — и только честный бар это показал.
Когда загрузочный акт готов, у него появляется второй потребитель: тот же спиннер годится для любой долгой операции внутри жизни прибора — записи в EEPROM, пересчёта таблиц, дождя сети. Один и тот же код анимации вызывается и на включении, и посреди работы — состояние загрузки и состояние ожидания у прибора выглядят одинаково, и пользователь перестаёт путать их с зависанием.
Прибор ожил, помигал и подмигнул вам пасхалкой — экранный минимум собран. Дальше по разделу идут тонкости: частичное обновление без полной перерисовки и диагностика чёрного экрана, который однажды случится и с вашей коробочкой. А загрузочные экраны из этого урока ставьте в каждый свой проект: полторы секунды честного рассказа дешевле любого объяснения.
Частые вопросы по теме урока
- Самая короткая версия номера кадра спиннера при периоде 150 мс?
- Номер кадра как функция времени не требует счётчика и не теряет кадры, даже если loop был занят; random дал бы дёрганый спиннер. kad = (millis() / 150) % 4 — функция времени
- Честный процент прогресса при пошаговой инициализации считается...
- proc = shag * 100 / VSEGO. Таймерный бар добегает до ста раньше работы и стоит — экран перегоняет инициализацию и лжёт. Из числа завершённых шагов, а не из таймера
- Зачем в счётчике пасхалки окно тишины 2 секунды?
- Без окна случайные касания за смену складываются, и секрет открывается сам. Окно делает серию намеренной: пять нажатий подряд, каждое быстрее двух секунд. Пауза больше окна обрывает серию — иначе счёт копится весь день и экран вылезает сам
- Как на плате нарисовать круглый спиннер без массивов кадров?
- Четвёртый аргумент drawCircleHelper — биты-квадранты: сдвиг 1 << kad обходит четверти по кругу. Массива картинок не нужно. drawCircleHelper(x, y, r, 1 << kad, цвет) — четверть окружности за кадр
- Спиннер идёт кадрами по millis и с одним display() на кадр. Что это даёт?
- Стекло показывает только собранный кадр: промежуточных нет, мерцать нечему. А отсутствие delay оставляет остальной loop отзывчивым. Кадр собирается в буфере и показывается целиком — без стробоскопа, а кнопка и звук остаются живыми