Инкубатор для перепелов вырос из одного порога в прибор с пятью настройками: целевая температура, гистерезис, длительность проветривания, сигнал тревоги, поправка датчика. Первая версия держала на корпусе пять кнопок — по одной на параметр, каждая тянула свой провод и свою ячейку EEPROM. Дети соседки разобрались с ночником за минуту, а с этой клавиатурой через месяц не разобрался я сам: какая кнопка за что отвечает, подсказывала только наклейка, отклеившаяся вместе с текстом.
Спасение подсмотрел в машине: панель климата устроена иначе — один экран, три кнопки, а пунктов десятки. Я повторил ту логику. LCD 16x2 по шине I2C занимает всего A4 и A5 — его подключение было разобрано в уроке про первый текст на LCD, — плюс три кнопки. Порог датчика мы уже научились двигать кнопками и сохранять по таймеру в прошлом уроке; здесь вопрос шире — как уложить десяток параметров в один интерфейс и не писать память насмерть.
Схема интерфейса: в режиме просмотра кнопка меню листает пункты, плюс и минус листают тоже. Второе нажатие меню на выбранном пункте — вход в правку: теперь плюс и минус меняют значение, а меню — выход с сохранением. Экран показывает имя пункта и число; на первой строке горит стрелка или звёздочки — сразу видно, смотрим или правим. EEPROM трогается ровно один раз — в момент выхода из правки. Дальше: конечный автомат на два состояния, полный скетч, перерисовка без мерцания и раскладка структуры в памяти.
Автомат VIEW и EDIT: состояния вместо флагов
Интерфейс меню — типичный конечный автомат: то, что мы строили на светофоре в уроке про автоматы, здесь живёт в двух состояниях. VIEW — просмотр: кнопки ходят по пунктам. EDIT — правка: плюс и минус двигают значение выбранного параметра. Меню переводит VIEW в EDIT, а из EDIT выходит с сохранением. Вся логика — switch (state) с двумя ветками; тем, кто привык к режимам светодиода, приём знаком из урока про switch-case.
Почему сохранение именно на выходе, а не на каждом шаге: правка порога от 400 до 530 — это 26 нажатий, и писать на каждом значило бы сделать 26 записей вместо одной. EEPROM.put при выходе пишет структуру целиком одним вызовом — единственная запись на всю сессию правки; как put раскладывает структуру по ячейкам и почему совпавшие байты не тратят ресурс, считается в уроке про put и get. Время тоже в порядке: структура из трёх полей — шесть байт, шесть циклов записи по 3,3 мс — около 20 мс один раз, в момент, когда пользователь и так смотрит на экран, а не ждёт отклика.
Ещё одно следствие правки в RAM: значения работают сразу. Поменяли порог в EDIT — ночник уже реагирует по-новому, хотя в памяти по-прежнему старое число. Это правильно и для тюнинга, и для защиты: пользователь сначала доводит настройку до ума, и только доведённая версия попадает в память. Запись здесь — фиксация решения, а не часть самой правки.
Отзывчивость держится на двух решениях. Первое: кнопки читаются на каждом проходе loop, без delay — реакция на нажатие занимает один цикл, а задержки длиннее 100 мс человек уже замечает. Второе: экран перерисовывается не постоянно, а по флагу dirty — значение поменялось, картинка устарела, рисуем заново. Без флага перерисовка шла бы каждые 15 мс, и это уже отдельная история про мерцание — она в заметке ниже.
#include <EEPROM.h>#include <Wire.h>#include <LiquidCrystal_I2C.h> LiquidCrystal_I2C lcd(0x27, 16, 2); struct Settings { int porog; // порог срабатывания int gist; // гистерезис byte zvuk; // 0 выключен, 1 включён}; const int ADDR_CFG = 10;Settings cfg = {500, 20, 1}; byte state = 0; // 0 VIEW, 1 EDITbyte item = 0; // 0 порог, 1 гистерезис, 2 звукbool dirty = true;bool prevMenu = true;bool prevPlus = true;bool prevMinus = true; void saveCfg() { EEPROM.put(ADDR_CFG, cfg); tone(8, 1200, 200); Serial.print("save porog="); Serial.print(cfg.porog); Serial.print(" gist="); Serial.println(cfg.gist);} void draw() { lcd.clear(); lcd.setCursor(0, 0); if (state == 0) { lcd.print("> "); } else { lcd.print("* "); } if (item == 0) lcd.print("Porog"); if (item == 1) lcd.print("Gist"); if (item == 2) lcd.print("Zvuk"); lcd.setCursor(0, 1); if (item == 0) lcd.print(cfg.porog); if (item == 1) lcd.print(cfg.gist); if (item == 2) { if (cfg.zvuk == 1) lcd.print("vkl"); else lcd.print("vykl"); } dirty = false;} void setup() { Serial.begin(9600); lcd.init(); lcd.backlight(); pinMode(2, INPUT_PULLUP); pinMode(3, INPUT_PULLUP); pinMode(4, INPUT_PULLUP); pinMode(8, OUTPUT); EEPROM.get(ADDR_CFG, cfg); if (cfg.porog < 20 || cfg.porog > 1000) { cfg = {500, 20, 1}; // страховка чистой памяти } draw();} void loop() { bool menu = digitalRead(2); bool plus = digitalRead(3); bool minus = digitalRead(4); if (prevMenu && !menu) { if (state == 0) { state = 1; // вошли в правку } else { state = 0; // вышли — сохранить saveCfg(); } dirty = true; } if (prevPlus && !plus) { if (state == 0) { item = (item + 1) % 3; // следующий пункт } else { if (item == 0) cfg.porog = constrain(cfg.porog + 5, 20, 1000); if (item == 1) cfg.gist = constrain(cfg.gist + 5, 0, 50); if (item == 2) cfg.zvuk = 1; } dirty = true; } if (prevMinus && !minus) { if (state == 0) { item = (item + 2) % 3; // предыдущий пункт } else { if (item == 0) cfg.porog = constrain(cfg.porog - 5, 20, 1000); if (item == 1) cfg.gist = constrain(cfg.gist - 5, 0, 50); if (item == 2) cfg.zvuk = 0; } dirty = true; } prevMenu = menu; prevPlus = plus; prevMinus = minus; if (dirty) draw(); delay(15);}Флаг dirty — сердце отзывчивого экрана. Кнопки меняют переменные и ставят его, а draw() выполняется только когда флаг стоит; в остальное время loop крутится впустую за десятки микросекунд. Для пользователя это выглядит как мгновенная реакция: нажал — увидел. Для платы — как тишина на шине I2C в промежутках: экран не спамит трафиком, и соседям по A4/A5 ничего не мешает. Сам draw() на реальном LCD занимает пару миллисекунд — даже если попасть в него во время нажатия кнопки, пропуск одного опроса не заметен.
Правка шагами по пять требует десяти нажатий на сотню единиц — для редкого тюнинга нормально. Если параметр ходит на большие дистанции, добавьте ускорение: чем дольше держат кнопку, тем крупнее шаг — 5, потом 20, потом 50. Реализация — тот же фронт-детект плюс таймер зажатия на millis, никакой новой магии.
Экран в 16 символов диктует дисциплину имён: «Gist» вместо «Гистерезис», «Zvuk» вместо «Звук тревоги». Уложиться помогает шаблон первой строки: стрелка или звёздочки, имя, номер пункта — всё влезает с запасом. Если имя не сжимается до 12 символов, я листаю длинные описания по второй строке короткими фразами, а не пытаюсь впихнуть их целиком — экран 16x2 для названий, а не для документации. И ещё один момент — выключение питания прямо в правке: несохранённое значение умрёт вместе с RAM. Если прибор обесточивают часто и не церемонясь, дублируйте буфер по таймеру — куда и как, разобрано в уроке про питание и потерю настроек.
Что кладём в память: структура на три параметра
Все три настройки едут в одной структуре: int порог, int гистерезис, byte звук. Гистерезис я держу в int, хотя значения 0–50 поместились бы и в байт: поле одного типа с порогом позволяет переиспользовать один и тот же код правки и constrain, без отдельных веток для типов. На Uno компилятор уложит поля вплотную — четыре плюс четыре плюс один, шесть байт без выравнивающих промежутков; откуда берутся эти шесть и что было бы с выравниванием на других платах, считалось в уроке про int и float. put(10, cfg) пишет все шесть за вызов, get(10, cfg) на старте возвращает их на место. Адрес я выбрал 10, а не 0, и не случайно: нулевые адреса у меня помечены под служебный маркер первого запуска — об этом последний урок трио.
Пункт «звук» намеренно сделан переключателем: в правке плюс ставит 1, минус — 0. Для двоичных настроек это самый честный интерфейс — крутить шагами по пять нечего. А вот что действительно стоит держать в голове: новый параметр добавляется полем структуры и сдвигает раскладку — старые байты в EEPROM начнут читаться с чужими ролями. Как пережить смену раскладки, покажет урок про первый запуск; на сегодня правило простое — меняете структуру, меняйте и версию конфига.
Страховку после get() вы видели в setup: если порог вылез за границы — заливаем дефолты. Это латание, а не лечение: латок не видно снаружи, а прибор с тихо подменёнными настройками ведёт себя правдоподобно — никаких вспышек, просто не так, как рассчитывал хозяин. Правильнее ещё до чтения понять, был ли кто-то в памяти. Как отличить свежую плату от настроенной и почему у конфига должна быть версия — следующий урок.
| Кнопка | В просмотре (VIEW) | В правке (EDIT) |
|---|---|---|
| Меню (D2) | вход в правку выбранного пункта | сохранить через put и вернуться к просмотру |
| Плюс (D3) | следующий пункт | значение +5; для звука — включить |
| Минус (D4) | предыдущий пункт | значение −5; для звука — выключить |
| Экран, первая строка | > Porog — стрелка ведёт текущий пункт | * Porog * — звёздочки значат, что значение можно править |
| Правка без сохранения | — | не предусматриваю: изменение считается намеренным; для общедоступных приборов добавьте таймаут 30 с с авто-сохранением |
- Меню — конечный автомат VIEW/EDIT: switch по состоянию, переходы по кнопке меню; новый пункт добавляется одной веткой if
- EEPROM.put срабатывает один раз — на выходе из правки: 26 нажатий дают одну запись вместо двадцати шести
- Перерисовка по флагу dirty: lcd.clear() на каждом проходе мерцает, сбрасывает курсор в (0,0) и грузит I2C около 4 мс каждые 15 мс
- Структура int+int+byte на Uno — шесть байт вплотную; адрес 10 оставлен под служебные ячейки первого запуска
- Отклик: кнопки без delay читаются каждый loop, задержки от 100 мс человек замечает; бипы подтверждают каждый переход, put — длинным тоном
- 1. Когда скетч меню сохраняет структуру настроек?
- 2. Что вернёт EEPROM.get(10, cfg) на чистой с завода плате без маркера?
- 3. Почему lcd.clear() на каждом проходе loop — плохая идея?
- 4. Сколько байт займёт struct {int porog; int gist; byte zvuk;} на Arduino Uno?
- 5. Зачем меню нужен флаг dirty?
Частые вопросы по теме урока
- Когда скетч меню сохраняет структуру настроек?
- Правка меняет только RAM-копию. put выполняется на выходе из EDIT: одна запись на всю сессию вместо одной на нажатие. Один раз — при выходе из правки кнопкой меню
- Что вернёт EEPROM.get(10, cfg) на чистой с завода плате без маркера?
- get раскладывает байты как есть. Свежая ячейка — 255, и верить прочитанному без проверки маркера нельзя. Поля из 255: порог 1020, гистерезис после приведения отрицательный
- Почему lcd.clear() на каждом проходе loop — плохая идея?
- Три беды сразу: видимая пустая строка между стиранием и выводом, сброшенный курсор и около 4 мс трафика каждые 15 мс. Экран мерцает, курсор сбрасывается в (0,0), шина I2C грузится впустую
- Сколько байт займёт struct {int porog; int gist; byte zvuk;} на Arduino Uno?
- Четыре плюс четыре плюс один: на AVR нет выравнивающих промежутков внутри структуры, компилятор кладёт поля подряд. 6 — AVR кладёт поля вплотную
- Зачем меню нужен флаг dirty?
- Переменные меняют флаг, draw() рисует по флагу. Без него экран перерисовывался бы каждые 15 мс вхолостую. Чтобы draw() выполнялся только при изменении — нет мерцания и лишнего I2C-трафика