Инкубатор на Uno: переворот лотков раз в четыре часа, вентиляция, сигнализация по температуре. Первая версия жила на шести булевых флагах: nagrevVkl, ventVkl, trevoga, perevorotIdet, ozhidanie, nochRezhim. Пока я тестировал режимы по одному, всё было хорошо. А потом флаги начали путаться в парах: вентилятор крутился во время тревоги, потому что в одной из шести веток я погасил свой флаг, но забыл чужой. Вечер с распечаткой скетча и карандашом ушёл на то, чтобы выписать все сочетания и найти, какие из них я не проектировал вообще. Их оказалось одиннадцать — из шестидесяти четырёх возможных, и все они были достижимы.
Второй заход я сделал через состояния: ОТДЫХ, ПОДОГРЕВ, ПРОВЕТРИВАНИЕ, ПЕРЕВОРОТ, ТРЕВОГА. Скетч в каждый момент находится ровно в одном из них — несовместимые сочетания исчезли как класс, их больше нельзя выразить ни одной строкой кода. Это и есть конечный автомат: не модный термин из учебника, а способ сделать запрещённые режимы невыразимыми. Заодно скетч стал читаемым: ветка switch с именем PODOGREV говорит сама за себя.
Здесь раскладываю подход целиком: как выбирать состояния по задаче, где ставить действия — на входе в состояние, а не внутри, и главная практика отладки, которую я забрал себе навсегда — журнал переходов в Serial. Типы уже разобраны в уроке про struct и enum, автомат на одной кнопке с антидребезгом — в уроке про кнопку-автомат; здесь автомат вырастает до целого устройства.
Состояния выбирает задача, а не программист
Хороший вопрос для отбора состояний: если я вошёл в комнату и смотрю на устройство молча одну минуту, что я вижу? Нагреватель тёплый, вентилятор молчит, лампочка мигает — каждый различимый набор поведения и есть кандидат. Заметьте, что «вентилятор включён» — не состояние, а признак: вентилятор включается при проветривании, но может и при продуве после переворота. Состояние отвечает на вопрос «в каком режиме живёт устройство», а не «что сейчас делает конкретная нога».
Сколько их должно быть? В самодельных узлах обычно выходит от трёх до шести. Если список перевалил за десяток — вы почти наверняка перечислили не режимы, а комбинации признаков. Тогда либо выделить общий подслучай, либо разнести устройство на два независимых автомата: климат — один, механика переворота — второй, и они друг о друге не знают. Мой инкубатор после рефакторинга жил в пяти состояниях, и это число не менялось полгода.
Переходы выписываются списком до всякого кода: из ОТДЫХ в ПОДОГРЕВ — температура ниже 37,5 °C; из ПОДОГРЕВ в ПРОВЕТРИВАНИЕ — выше 38,2 °C; из ПРОВЕТРИВАНИЯ в ОТДЫХ — прошло пять минут; в ТРЕВОГУ — из любого состояния, если температура вышла за коридор 33–41 °C. Этот список и есть автомат. Код — лишь его транскрипция, и когда список лежит перед глазами, пропущенный переход видно на бумаге, а не на сгоревшей кладке.
У каждого состояния есть своё время, и жить оно должно в одном месте: таймер входа tVhoda запоминает millis() в момент перехода, а пребывание в состоянии считается как millis() минус tVhoda. Из ПРОВЕТРИВАНИЯ выйти через пять минут — это millis() - tVhoda >= 300000UL, и никаких отдельных переменных «когда включился вентилятор» заводить не нужно: вход в состояние уже отметил время. Половина флагов из первого варианта моего инкубатора держалась ради именно таких отметок — автомат забрал их бесплатно.
Отдельный признак зрелого автомата — недостижимые состояния исчезают вместе с мёртвыми ветками. В первой версии на флагах у меня был код тревоги, который физически не мог выполниться: условие ставило флаг, который тут же гасила соседняя ветка. Компилятор такое молча проглатывает, а на бумаге со списком переходов дыра видна за минуту. Проверяйте автомат так: для каждого состояния выпишите, откуда в него можно войти и куда выйти; состояние без входов или без выходов — это баг в проекте, а не в коде.
enum Sostojanie { OTDYH, PODOGREV, PROVETRIVANIE, PEREVOROT, TREVOGA }; const int PIN_TEN = 12; // нагреватель через реле-модульconst int PIN_VENT = 11; // вентилятор через мосфетunsigned long tVhoda = 0; // millis() входа в текущее состояниеunsigned long tPerevora = 0; // millis() прошлого переворотаSostojanie sost = OTDYH; // имена состояний одной строкой: журнал в Serial читается человекомvoid pechatSost(Sostojanie s) { switch (s) { case OTDYH: Serial.print(F("OTDYH")); break; case PODOGREV: Serial.print(F("PODOGREV")); break; case PROVETRIVANIE: Serial.print(F("PROVETRIVANIE")); break; case PEREVOROT: Serial.print(F("PEREVOROT")); break; case TREVOGA: Serial.print(F("TREVOGA")); break; }} // единственные ворота между состояниями: смена, таймер входа, входные действияvoid perejtiV(Sostojanie novoe) { if (novoe == sost) return; Serial.print(millis()); Serial.print(F(" ms: ")); pechatSost(sost); Serial.print(F(" -> ")); pechatSost(novoe); Serial.println(); sost = novoe; tVhoda = millis(); digitalWrite(PIN_TEN, sost == PODOGREV ? HIGH : LOW); digitalWrite(PIN_VENT, sost == PROVETRIVANIE ? HIGH : LOW);} int celsius10() { return 376; } // заглушка: ваш датчик возвращает t умножить на 10 void setup() { pinMode(PIN_TEN, OUTPUT); pinMode(PIN_VENT, OUTPUT); Serial.begin(9600); Serial.println(F("inkubator start"));} void loop() { int t10 = celsius10(); if (t10 < 330 || t10 > 410) { perejtiV(TREVOGA); // из любого состояния } else if (sost == TREVOGA) { // выход из тревоги - только когда температура вернулась в коридор perejtiV(OTDYH); } else if (sost == PEREVOROT) { if (millis() - tVhoda >= 30000) perejtiV(OTDYH); } else if (millis() - tPerevora >= 14400000UL) { perejtiV(PEREVOROT); tPerevora = millis(); } else if (sost == OTDYH && t10 < 375) { perejtiV(PODOGREV); } else if (sost == PODOGREV && t10 > 382) { perejtiV(PROVETRIVANIE); } else if (sost == PROVETRIVANIE && millis() - tVhoda >= 300000UL) { perejtiV(OTDYH); }}Три вещи в этом каркасе делают всю работу. Первая — все переходы идут через одну функцию perejtiV(): смена состояния, перезапуск таймера входа и входные действия живут в одном месте, и ни одно состояние нельзя «незаметно» покинуть мимо журнала. Вторая — действия стоят на входе в состояние: digitalWrite в perejtiV исполняется один раз за переход, а не четыреста раз в секунду в ветке case. Третья — сам switch в loop только решает, куда переходить, и не содержит ни одного digitalWrite: механика отделена от решения, поэтому сменить реле на мосфет или добавить сигналку можно, не трогая логику.
Обратите внимание на F() в печатях — почему строки в скобках живут во флеше, а не в RAM, целиком посвящён следующий урок кластера. И ещё деталь: выход из ТРЕВОГИ я специально разрешаю только при вернувшейся температуре — иначе автомат мигом гасит сигналку и тут же в неё возвращается, получаются нервные дребезги режимов. Каждый выход из аварийного состояния должен опираться на то же условие, что и вход, плюс подтверждение человека или времени.
Отладка состояний: журнал переходов в Serial
Главный приём, ради которого всё затевалось: печатать не состояние, а переход. Наивный вариант — Serial.println в конце loop — засыпает монитор сотнями одинаковых строк, и среди них невозможно найти момент, когда что-то пошло не так. Вариант рабочий — печать только в perejtiV: одна строка на событие, с меткой millis. Сутки работы инкубатора занимают двадцать строк и читаются как история болезни: 14:02:07 ПОДОГРЕВ, 14:04:11 ПРОВЕТРИВАНИЕ — видно и расписание, и скорость реакции, и то, что тревога в три ночи была краткой.
Метки millis удобнее часов: их можно сопоставить с журналом датчика или с записью логгера, где тоже тикает millis(). Если хочется человеческого времени — печатайте его раз в setup и считайте смещение; механику долгого счёта без переполнения я разбирал в уроке про 49 дней millis. Для постоянного узла полезно раз в N переходов сохранять последний переход и счётчик в EEPROM — после внезапного ребута видна предыстория, а не чистый лист.
Журналу можно добавить телеметрию, не разводя канцелярию. Счётчик переходов печатайте в каждом отчёте: у здорового узла он растёт ровно, и резкий излом кривой показывает час, когда начало барахлить железо — у меня так всплыл вентилятор с рассыпающейся смазкой, который стал гонять автомат по кругу ПРОВЕТРИВАНИЕ-ОТДЫХ вдвое чаще. Вторая фишка — имена состояний строкой: маленький switch pechatSost, который печатает PODOGREV вместо цифры 1. Монитор читает человек, и «в 14:02:07 было 3» и «в 14:02:07 был ПЕРЕВОРОТ» — сообщения разной полезности.
Две грабли, на которые я наступал. Delay внутри состояния ломает автомат: пять секунд delay(5000) — это пять секунд, когда ни одно условие перехода не проверяется, тревога в это время немая; таймеры состояния держите на millis, приёмы — в уроке про планировщик задач. Вторая — проверка условий в неправильном порядке: условие тревоги обязано стоять первым и перекрывать всё остальное, иначе клинический случай — автомат сначала увлечённо греет, а тревогу рассматривает в остатке цикла.
Самый быстрый тест автомата на живом устройстве я вывел из двух неудачных вечеров: с включённым журналом чинится за один прогон — видно, куда автомат ушёл и по какому условию; без журнала я дважды уверенно правил не то место скетча, а виноват был переход, которого в списке не проектировал. Теперь список переходов на бумаге и журнал в мониторе для меня — пара, которая не разлучается: одно без другого стоит ровно те же вечера.
| Сценарий | Зоопарк булевых флагов | Автомат на enum |
|---|---|---|
| Проветривание во время тревоги | достижимо: чужой флаг забыли погасить в одной из веток | не выразимо: состояние одно, режимы исключают друг друга |
| Новый режим в устройстве | пересмотреть все ветки, где флаги пересекаются | один case и пара строк в perejtiV |
| Что делала плата ночью | шесть переменных и никакой истории | журнал переходов: строка на событие с меткой millis |
| Число режимов в памяти | два в степени n сочетаний, половина запрещённые | ровно n именованных состояний |
- 1. Когда печатать состояние в Serial-журнал?
- 2. Где включать нагреватель при входе в состояние ПОДОГРЕВ?
- 3. Сколько сочетаний значений дают пять булевых флагов?
- 4. Что делает delay(5000) внутри ветки состояния?
- 5. Список состояний разросся за десяток — что это значит?
- Состояния выбирает задача: перечислите различимые режимы поведения устройства — обычно их 3-6, а комбинации признаков — не состояния
- Один автомат — одна переменная enum: запрещённые сочетания режимов становятся невыразимыми, а не «запрещёнными на честном слове»
- Все переходы — через одну функцию: смена состояния, таймер входа и входные действия в одном месте, выход из тревоги подтверждается тем же условием, что и вход
- Журнал переходов с меткой millis — главный инструмент отладки: одна строка на событие читается как история болезни узла
- Delay внутри состояния глушит все проверки на своё время — таймеры состояний только на millis
Частые вопросы по теме урока
- Когда печатать состояние в Serial-журнал?
- Печать на переходе даёт одну строку на событие с меткой millis. Печать каждый круг засыпает монитор сотнями одинаковых строк, среди которых момент сбоя не найти. только при переходе, из функции смены состояния
- Где включать нагреватель при входе в состояние ПОДОГРЕВ?
- Входные действия живут в perejtiV: они исполняются один раз за переход. digitalWrite в теле case будет повторяться четыреста раз в секунду и рассинхронизируется при любом новом режиме. в функции perejtiV сразу после смены состояния
- Сколько сочетаний значений дают пять булевых флагов?
- Два в пятой степени — 32 сочетания, и компилятор не запрещает ни одно, включая те, что вы не проектировали. Пять именованных состояний оставляют ровно пять вариантов. тридцать два
- Что делает delay(5000) внутри ветки состояния?
- На пять секунд автомат замолкает: тревога не проверяется, кнопки не читаются. Таймеры состояния держат на millis из планировщика, а не на delay. пять секунд ни одно условие перехода не проверяется
- Список состояний разросся за десяток — что это значит?
- Десяток состояний — почти всегда комбинации флагов под новым именем. Лечится выделением общего подслучая или разносом на два независимых автомата: климат и механика. перечислены не режимы, а комбинации признаков