У прибора три экрана, а в корпусе осталась одна дырка под кнопку. Знакомая ситуация: статус с показаниями, график за последний час и страница порога — всё просится на дисплей, а интерфейсного места нет. Выход стар как мир — вкладки: короткое нажатие листает страницы по кругу, каждая рисует свой кадр. В первый раз я сделал это в лоб и получил прибор, который при одном нажатии перепрыгивал две страницы, а при удержании крутил их как рулетку. Грабли обе про кнопку, и обе лечатся до того, как тронешь дисплей.
Кнопочную часть мы уже закрепляли: навигация по меню кнопками разобрана в уроке про меню на кнопках, а вложенные экраны с выходом — в уроке про автомат экранов. Разница в модели: там меню с выбором пункта, здесь плоский круг страниц — как вкладки браузера, только одна кнопка. Дашборд со сводкой и графиком на одной странице показан в уроке про дашборд — оттуда можно взять содержимое вкладок, здесь фокус на механике переключения.
Разберём: хранение текущей страницы, фронт кнопки с защитой от дребезга, три функции отрисовки и индикатор вкладок в шапке.
Отдельная история — физика кнопки в корпусе: она одна, и место для неё выбирают раз и навсегда. Ставьте её туда, куда палец попадает без взгляда: листание вкладок — операция вслепую, пользователь смотрит на экран, а не на кнопку. Кнопка в неудобном месте перечёркивает весь интерфейс: страницы работают, а пользоваться ими не хочется.
Три страницы, одна кнопка, фронт
Вся механика — одна переменная stranica и одна проверка фронта. digitalRead возвращает уровень: пока кнопка зажата, 1 приходит тысячи раз за удержание, и наивная проверка if (digitalRead(2)) перещёлкает все страницы заодно с пальцем. Нужен фронт — момент перехода из 0 в 1: храним прошлое состояние в lastKn и реагируем только когда теперь 1, а раньше был 0. Одно нажатие — один переход, сколько бы циклов loop ни прошло.
Второй слой — дребезг: контакты кнопки при замыкании дребезжат 5-20 миллисекунд, и чистый фронт на цифровом входе превращается в пачку импульсов. Без обработки прибор честно сделает два-три переключения за одно нажатие. Лечение копеечное: после зафиксированного фронта пауза 150-200 миллисекунд, в течение которой фронт игнорируется, — этого хватает на девять из десяти кнопок. Жёсткий вариант с прерыванием и millis-глушителем разобран в уроке про антидребезг, здесь достаточно простого затыкания.
Каждая страница — своя функция отрисовки, и переключение — это только смена номера и один вызов. Статус обновляет значения каждые полсекунды, график подкладывает точку по своему таймеру, настройки меняют порог ручкой. Общего у них только шапка с индикатором вкладок: три позиции, активная — закрашенная.
Схема кнопки важнее, чем кажется. При pinMode INPUT_PULLUP кнопка замыкает вход на землю: зажата — на входе ноль, отпущена — единица от внутренней подтяжки. Фронт 0-в-1, на который реагирует perehod, — это момент отпускания пальца: листание происходит при отпускании, и это даже удобнее — долгое удержание не тратит страницы. Дребезг размыкания гасится тем же окном защиты: 200 миллисекунд после фронта любые колебания входа игнорируются.
| Страница | Показывает | Обновление | Точка вкладки |
|---|---|---|---|
| Статус | Обороты и состав из датчика | Каждые 500 мс | Первая, закрашена при stranica=0 |
| График | Лента точек за минуту | Новая точка раз в секунду | Вторая, при stranica=1 |
| Настройки | Порог, правится ручкой A0 | По факту вращения ручки | Третья, при stranica=2 |
Частоты обновления и индикатор вкладок
Частота перерисовки — третье, о чём забывают. Полный кадр по I2C — 1024 байта, около 25-30 миллисекунд на 400 кГц: если пихать display() в каждый проход loop, шина занята почти целиком, а выигрыша нет — глаз не различает чаще пяти кадров в секунду. Правило: страница статуса обновляется раз в 500 мс, график — по новой точке, настройки — по сдвигу значения за мёртвую зону в две-три единицы. Отрисовка по событию, а не по кругу — иначе вкладки съедят весь процессор, и кнопка начнёт опаздывать с ответом.
Содержимое страницы проектируйте под одно действие. Статус — три-четыре строки значений и ничего интерактивного: это витрина. График — единственная страница с точками и осью: лента с кольцевым буфером уже собрана в уроке про самописец с буфером, здесь она занимает своё место во вкладке. Настройки — один параметр и подсказка снизу: две правки одновременно на 128×64 не помещаются, а читатель путается, что именно крутит ручка. Если параметров набирается много — это не страница, а меню со строками и курсором, приём из урока про инверсную строку.
Индикатор вкладок — три позиции в правом верхнем углу: пустой кружок drawCircle и закрашенный fillCircle у активной. Альтернатива — белая плашка с чёрным номером, но кружок дешевле по вызовам и не спорит с содержимым. Ставьте индикатор на одном и том же месте всех страниц: он единственный элемент, который переезжает из кадра в кадр без изменений, и глаз цепляется за него как за закладку в книге.
Порог со страницы настроек — живая переменная, которой пользуется вся логика прибора. Если прибор выключат, порог умрёт с питанием: сохранение в EEPROM разобрано в уроке про первый байт EEPROM, а здесь порог держится в оперативке и печатается в Serial — серийный лог интерфейса экономит часы гадания, что прибор думает о ваших нажатиях.
Adafruit_SSD1306 display(128, 64, &Wire, -1); int stranica = 0;int porog = 60;long tStat = 0;long tTochka = 0;long tZashita = 0; // okno ignorirovaniya drebezgaint lastKn = 0;int bufGraf[16];int glub = 0; void risujVkladki() { for (int i = 0; i < 3; i++) { int x = 104 + i * 8; if (i == stranica) { display.fillCircle(x, 4, 3, SSD1306_WHITE); } else { display.drawCircle(x, 4, 3, SSD1306_WHITE); } }} void risujStatus() { display.clearDisplay(); display.setCursor(0, 0); display.print("STATUS"); display.setCursor(0, 16); display.print("Oboroty: "); display.print(map(analogRead(A0), 0, 1023, 0, 3000)); display.setCursor(0, 32); display.print("Porog: "); display.print(porog); risujVkladki(); display.display();} void risujGrafik() { display.clearDisplay(); display.setCursor(0, 0); display.print("GRAFIK"); display.setCursor(0, 16); int n = map(analogRead(A0), 0, 1023, 1, 18); for (int i = 0; i < n; i++) { display.print("*"); } display.setCursor(0, 32); display.print(bufGraf[0]); display.print(" -> "); display.print(bufGraf[glub - 1]); risujVkladki(); display.display();} void risujNastroyki() { display.clearDisplay(); display.setCursor(0, 0); display.print("NASTROIKI"); display.setCursor(0, 16); display.print("Porog: "); display.print(porog); display.setCursor(0, 32); display.print("Ruchka A0 menyaet"); risujVkladki(); display.display();} void perehod() { stranica = (stranica + 1) % 3; tZashita = millis(); Serial.print("stranica="); Serial.println(stranica); if (stranica == 0) risujStatus(); if (stranica == 1) risujGrafik(); if (stranica == 2) risujNastroyki();} void setup() { Serial.begin(9600); pinMode(2, INPUT_PULLUP); display.begin(SSD1306_SWITCHCAPVCC, 0x3C); display.setTextColor(SSD1306_WHITE); risujStatus();} void loop() { int kn = digitalRead(2); if (kn == 1 && lastKn == 0 && millis() - tZashita > 200) { perehod(); } lastKn = kn; long t = millis(); if (stranica == 0 && t - tStat >= 500) { tStat = t; risujStatus(); } if (stranica == 1 && t - tTochka >= 1000) { tTochka = t; if (glub < 16) { bufGraf[glub] = map(analogRead(A0), 0, 1023, 0, 99); glub++; } else { for (int i = 0; i < 15; i++) bufGraf[i] = bufGraf[i + 1]; bufGraf[15] = map(analogRead(A0), 0, 1023, 0, 99); } risujGrafik(); } if (stranica == 2) { int novy = map(analogRead(A0), 0, 1023, 0, 100); if (abs(novy - porog) > 2) { porog = novy; risujNastroyki(); } }}Функция perehod — единственное место, где меняется номер страницы: она крутит индекс по модулю три, ставит метку защиты от дребезга и вызывает нужную отрисовку. Весь интерфейс перестраивается за один кадр, и «мусорный» переходный экран невозможен по построению: каждая страница рисуется целиком с чистого clearDisplay.
Круг из трёх страниц почти никогда не остаётся трёхстраничным: через неделю прибор просит четвёртую с уставками и пятую с версией прошивки. Модель это переживает спокойно: строка (stranica + 1) % 5 и ещё две ветки отрисовки. А вот кнопка одна на всё, и удержание стоит использовать: короткое нажатие листает вперёд, удержание дольше 1200 миллисекунд возвращает на страницу статуса — домой, как кнопка Home. Ловится это тем же фронтом: запомнили момент нажатия, в момент отпускания сравнили с millis, и если зажато было дольше порога — переход на нулевую страницу.
Зажмите кнопку в симуляторе и следите за Serial: один переход на нажатие, несмотря на то что сигнал на входе держится сколько угодно. Окно защиты 200 мс видно и в другом: быстрые двойные клики превращаются в одно переключение — если для вашего прибора двойной клик важен, окно придётся ужать до 80-100 мс и ловить паузы между нажатиями. Точки-вкладки в симуляторе нарисованы скобками с номерами — на плате это те же три позиции в правом верхнем углу, только круглые.
Тумблер вместо кнопки для вкладок я пробовал и забросил: фиксация состояния не даёт фронта, листать приходится дёргать рычажок, и рука устаёт быстрее прибора. Кнопка с фронтовым детектором честнее: нажатие — событие, отпускание — свободное время. Если кнопок всё же две, вторую стоит повесить на обратный ход — листание назад убирает полный круг через все страницы, а пара вперёд-назад закрывает девять из десяти пожеланий пользователей прибора.
Модуль в (stranica + 1) % 3 замыкает круг сам: после двойки индекс возвращается на ноль. Осторожнее с листанием назад: в C++ остаток от отрицательного числа отрицателен, и (stranica - 1) % 3 при stranica=0 даст минус единицу — записывайте (stranica + 2) % 3. Это не паранойя, а классическая ловушка первого листания назад: страница номер минус один не рисуется, и прибор замирает.
Имена страниц для Serial-лога удобно держать в массиве строк: imena[3] с тремя подписями — и печатать imena[stranica] при каждом переходе. Лог становится читаемым для человека: в записи видно, что между nastroiki и status был именно переход, а не сбой отрисовки, и спорные сценарии у пользователя воспроизводятся по записи с первого раза.
- 1. Почему наивная проверка if (digitalRead(2)) листает страницы пачкой?
- 2. Сколько длится типичный дребезг контактов кнопки?
- 3. Сколько байт кадра 128×64 передаётся по I2C за один display()?
- 4. Какая частота перерисовки у страницы настроек?
- 5. Что делает индикатор вкладок в шапке?
- Вкладки — плоский круг страниц по модулю: одна кнопка вместо трёх, каждая страница — своя функция отрисовки
- Фронт кнопки — переход 0-в-1 через lastKn; уровень в if листает страницы пачкой за удержание
- Дребезг 5-20 мс глушится окном 150-200 мс после фронта; окно ставится на оба края нажатия
- Каждой странице своя частота: статус 500 мс, график по точке, настройки по сдвигу за мёртвую зону
- Кадр по I2C — 1024 байта и 25-30 мс: перерисовка по событию, а не в каждый проход loop
Частые вопросы по теме урока
- Почему наивная проверка if (digitalRead(2)) листает страницы пачкой?
- digitalRead возвращает уровень, а не событие. Пока палец на кнопке, каждая итерация loop видит 1 — нужен фронт через сравнение с прошлым состоянием. Уровень HIGH держится всё удержание, а loop проходит тысячи раз
- Сколько длится типичный дребезг контактов кнопки?
- Контакты дребезжат 5-20 мс — отсюда окно защиты 150-200 мс после фронта, которого хватает для подавляющего большинства кнопок. 5-20 миллисекунд
- Сколько байт кадра 128×64 передаётся по I2C за один display()?
- 128×64 пикселя упаковываются в 1024 байта: восемь пикселей в байте. На 400 кГц передача занимает 25-30 мс — потому обновление по событию. 1024
- Какая частота перерисовки у страницы настроек?
- Настройки перерисовываются, когда значение реально сдвинулось за мёртвую зону 2-3 единицы: дрожь ручки не заставляет шину работать впустую. По изменению значения с мёртвой зоной
- Что делает индикатор вкладок в шапке?
- Три позиции в углу, активная закрашена fillCircle против пустых drawCircle: пользователь видит, где находится, без перелистывания. Подсвечивает активную страницу среди трёх