Инкубатор на шестьдесят яиц я собирал как одноразовый проект на один сезон. Он стоит пятый год, пережил три перепрошивки, двоих детей, покусавших кнопки, и одну молнию в линии. Всё, что выстроено в этом разделе, в нём работает одновременно: настройки держатся в EEPROM с первого урока про настройки, счётчики считают дни, журнал рассказывает, что было ночью. В этом финальном уроке я собираю прибор целиком — и главное здесь даже не скетч, а порядок, в котором куски складываются.
Прибор такой: термостат с реле, датчик на аналоговом входе, порог и калибровка меняются через Serial-меню, счётчик наработки помнит, сколько прибор трудился, журнал хранит восемь последних событий, заводской сброс возвращает всё к дефолтам. Скетч длинный, но нового в нём только клей: конфиг, кольцо и журнал мы уже написали в уроке про три паттерна, и я беру их как готовые блоки. Дальше по порядку: карта, скетч, разбор решений, план износа и приёмка.
Карта памяти прибора и Serial-меню
Карта памяти прибора занимает двадцать байт и рисуется одной строкой в шапке: 0 — magic 170, 1 — версия, 2-3 — порог, 4-5 — сдвиг калибровки, 6-9 — наработка в минутах, 10 — курсор журнала, 11-18 — слоты журнала, 19 — XOR-сумма. Двадцать байт из тысячи — можно расслабиться, но именно здесь лежит план износа: у каждого адреса своя частота записи, и главный ресурс прибора определяется не объёмом, а дисциплиной. Полный расклад — в таблице ниже, а сейчас два решения, которые делают прибор живучим.
Первое: наработка копится в RAM и сбрасывается в EEPROM раз в час. Счётчик минут тикает в переменной long, в память уходит часовой снимок — 24 записи в сутки вместо 1440. Арифметика ресурса простая: без буфера ячейки жили бы 100000/1440 — 69 дней, с буфером 100000/8760 — одиннадцать лет. Цена решения — потеря текущего часа при пропадании питания: для наработки это адекватно, для порога нет, поэтому порог пишется сразу при изменении. Второе: калибровка одноточечная — кнопка фиксирует текущее показание датчика как ноль, дальше прибор работает со сдвигом. Вторая точка с коэффициентом делается так же, но для датчика с линейным сдвигом мне хватает одной: точность прибора ограничена самим датчиком, а не калибровкой.
Меню — по Serial: команда s печатает статус, t с числом ставит порог, l дампит журнал, r делает заводской сброс с подтверждением. Почему Serial, а не кнопки с LCD? Экран и кнопки удорожают прибор, съедают пины и усложняют меню на четыре пункта до полноценного конечного автомата, а настраивают термостат раз в сезон — за это время ноутбук под рукой находится всегда. Когда прибор уезжает на объект без компьютера, я добавляю одну сервисную кнопку, но это уже другая история. Чтение строк из монитора с терминатором и trim разобрано в уроке про Serial.available — здесь тот же приём, только команды из одной-двух букв.
#include <EEPROM.h> // Карта: 0 magic, 1 ver, 2-3 порог, 4-5 сдвиг калибровки,// 6-9 наработка (long, минуты), 10 курсор журнала, 11-18 слоты, 19 XOR-суммаconst int A_MAGIC = 0;const int A_VER = 1;const int A_THR = 2;const int A_OFF = 4;const int A_MIN = 6;const int A_CUR = 10;const int A_JRN = 11;const int A_SUM = 19; const int PIN_BTN = 2; // калибровка нуляconst int PIN_RELAY = 7; int threshold = 400;int offset = 0;long minutes = 0; // буфер наработки в RAMlong lastMin = 0;long rMark = 0;int jrnCur = 0;int relayOn = 0; int xorSum() { int s = 0; for (int a = A_THR; a < A_SUM; a++) s = s ^ EEPROM.read(a); return s;} void wr16(int a, int v) { EEPROM.update(a, highByte(v)); EEPROM.update(a + 1, lowByte(v));} int rd16(int a) { return word(EEPROM.read(a), EEPROM.read(a + 1));} void wr32(int a, long v) { // младший байт - по меньшему адресу for (int i = 0; i < 4; i++) { EEPROM.update(a + i, v % 256); v = v / 256; }} long rd32(int a) { long v = 0; for (int i = 3; i >= 0; i--) { v = v * 256 + EEPROM.read(a + i); } return v;} void saveConf() { // сумма пишется последней wr16(A_THR, threshold); wr16(A_OFF, offset); EEPROM.update(A_SUM, xorSum());} void logEvent(byte code) { EEPROM.update(A_JRN + jrnCur, code); jrnCur = jrnCur + 1; if (jrnCur > 7) jrnCur = 0; EEPROM.update(A_CUR, jrnCur);} void factoryReset() { threshold = 400; offset = 0; minutes = 0; jrnCur = 0; for (int i = 0; i < 8; i++) EEPROM.update(A_JRN + i, 0); EEPROM.update(A_CUR, 0); saveConf(); logEvent(9);} void journalDump() { int cur = EEPROM.read(A_CUR); if (cur > 7) cur = 0; for (int k = 0; k < 8; k++) { int i = cur - 1 - k; if (i < 0) i = i + 8; Serial.print("ev="); Serial.println(EEPROM.read(A_JRN + i)); }} void setup() { Serial.begin(9600); pinMode(PIN_BTN, INPUT_PULLUP); pinMode(PIN_RELAY, OUTPUT); if (EEPROM.read(A_MAGIC) != 170 || EEPROM.read(A_VER) != 2 || EEPROM.read(A_SUM) != xorSum()) { factoryReset(); EEPROM.update(A_MAGIC, 170); EEPROM.update(A_VER, 2); Serial.println("pervyi zapusk: zality defolty"); } else { threshold = rd16(A_THR); offset = rd16(A_OFF); minutes = rd32(A_MIN); jrnCur = EEPROM.read(A_CUR); if (jrnCur > 7) jrnCur = 0; } Serial.println("menu: s - status, t N - porog, l - zhurnal, r r - sbros");} void loop() { long now = millis(); if (now - lastMin >= 60000) { // наработка: буфер в RAM lastMin = now; minutes = minutes + 1; if (minutes % 60 == 0) { // снимок раз в час wr32(A_MIN, minutes); EEPROM.update(A_SUM, xorSum()); Serial.print("hours="); Serial.println(minutes / 60); } } int raw = analogRead(A0); int val = raw - offset; if (relayOn == 0 && val > threshold) { // гистерезис 20 единиц relayOn = 1; digitalWrite(PIN_RELAY, HIGH); logEvent(1); } if (relayOn == 1 && val < threshold - 20) { relayOn = 0; digitalWrite(PIN_RELAY, LOW); logEvent(2); } if (digitalRead(PIN_BTN) == LOW) { // калибровка нуля offset = raw; wr16(A_OFF, offset); EEPROM.update(A_SUM, xorSum()); logEvent(3); Serial.print("calib off="); Serial.println(offset); delay(400); } if (Serial.available() > 0) { String cmd = Serial.readStringUntil(10); cmd.trim(); if (cmd == "s") { Serial.print("raw="); Serial.print(raw); Serial.print(" val="); Serial.print(val); Serial.print(" thr="); Serial.print(threshold); Serial.print(" min="); Serial.println(minutes); } else if (cmd.indexOf("t ") == 0) { int n = cmd.substring(2).toInt(); if (n > 0 && n < 1024) { threshold = n; saveConf(); logEvent(4); Serial.print("thr set "); Serial.println(threshold); } } else if (cmd == "l") { journalDump(); } else if (cmd == "r") { if (rMark > 0 && now - rMark < 10000) { factoryReset(); rMark = 0; } else { rMark = now; Serial.println("povtorite r za 10 s dlya sbrosa"); } } } delay(50);}Порядок в setup не случайный: сначала пины, потом проверка памяти, потом печать меню. Проверка тройная — magic, версия, сумма: чистая плата, старая прошивка и порванная запись дают три разные причины уйти на заводские дефолты, и все три ловятся одним if. Заметьте: factoryReset пишет дефолты и журнал, а magic с версией ставятся отдельно, после — я обжигался на порядке записи, когда контрольные байты обновлялись раньше данных, и прибор считал мусор валидным конфигом.
Подтверждение сброса — не паранойя. Первая r лишь ставит метку со временем, вторая в течение десяти секунд делает сброс: в мониторе это две буквы, а случайная строка мусора от глючного адаптера настройки уже не снесёт. Событие 9 в журнале при этом честно фиксирует заводской сброс — новый старт всегда можно отличить от нарочно стёртого. И гистерезис в двадцать единиц вокруг порога здесь вопрос жизни реле: без него контакт щёлкает на границе десятки раз в минуту, журнал заполняется событиями 1 и 2, а курсор стареет на глазах. Тот же приём экономил записи в прогнозаторе из урока про износ — гистерезис бережёт и память, и реле.
Коды событий в этом приборе растянулись до пяти: 1 — реле включилось, 2 — выключилось, 3 — калибровка, 4 — смена порога, 9 — заводской сброс. Список легко продолжить — не хватайте за это руки: журнал нужен для вопросов «что было ночью», а не для полного протокола. Каждый новый код — это документация в шапке скетча, иначе через год вы будете расшифровывать собственную нумерацию как древние письмена.
План износа и чек-лист запуска
Теперь то, ради чего весь раздел: прикинуть записи на годы вперёд. Наработка — 24 записи в сутки — даёт ячейкам 100000/8760, около одиннадцати лет. Порог и калибровка меняются от случая к случаю: десять записей в год — предел щедрости, эти ячейки переживут всё. Журнал при двадцати событиях в сутки делит износ на восемь слотов: каждая ячейка принимает 2,5 записи в день и живёт порядка девяноста лет. Быстрее всех стареет курсор с его 24 записями в сутки — те же одиннадцать лет, что у наработки. Первый кандидат на смерть появится через одиннадцать лет при самом частом блоке, и это приемлемо: бытовой прибор морально устареет раньше.
Если нужен запас — наработку и курсор разведите по кольцам, как счётчик из прошлого урока: восемь ячеек растянут одиннадцать лет до девяноста. Мой совет скромнее: заложите в прибор функцию печати всей карты и счётчик записей — когда через годы придёт время плановой замены, вы узнаете об этом из цифр, а не по молчащему порогу. Считайте прогноз до пайки, а не после: таблица ниже — это ровно та прикидка, которую я делаю на салфетке перед разводкой платы, и занимает она пять минут. А приёмку готового прибора провожу по чек-листу ниже: каждый пункт ловит свой класс проблем, и полный проход занимает один день.
- Печать всей карты EEPROM при первом старте: дефолты должны лечь по своим адресам, magic — стать 170
- Проверка на update: в скетче не осталось ни одного write без крайней необходимости
- Сутки на столе с прогнозатором записей из урока про ресурс: фактическая частота вместо ожидаемой
- Выдернуть питание на пике работы: прибор поднимается на дефолтах или последнем сохранении, без гибридных значений
- Двойной сброс: r без подтверждения память не трогает, r r — чистит и пишет событие 9
- Месяц на объекте: сверить счётчик наработки с календарём — разница говорит о перезагрузках, которых вы не видели
| Блок | Адреса | Частота записи | Прогноз жизни |
|---|---|---|---|
| Порог, калибровка, версия | 1-5 | при изменении | десятилетия |
| Наработка, long в минутах | 6-9 | раз в час из RAM-буфера | около 11 лет |
| Курсор журнала | 10 | при каждом событии | 24 события в сутки — около 11 лет |
| Слоты журнала, 8 шт | 11-18 | по событию, по кругу | при 24 событиях в сутки — порядка 90 лет |
| XOR-сумма | 19 | вместе со своим блоком | как самый частый блок |
- Карта прибора — 20 байт, у каждого адреса хозяин и своя частота записи
- Наработка копится в RAM и пишется раз в час: 24 записи в сутки дают ячейке 11 лет вместо 69 дней
- Гистерезис в 20 единиц вокруг порога спасает и реле, и журнал от десятков событий в минуту
- Заводской сброс — только с подтверждением и с записью события 9 в журнал
- При старте проверяется тройка magic-версия-сумма: пустая плата, старая прошивка и порванная запись лечатся дефолтами
- Приёмка: сутки с прогнозатором записей, выдёргивание питания, двойной сброс, сверка наработки за месяц
- 1. Наработка пишется в EEPROM раз в час. На сколько хватит ячеек?
- 2. Зачем копить минуты в RAM и сбрасывать в EEPROM раз в час?
- 3. Что проверяет прибор при старте, чтобы отличить пустую плату, старую прошивку и порванную запись?
- 4. Почему заводской сброс я делаю командой с подтверждением, а не тумблером на панели?
- 5. Журнал хранит 8 слотов. Что попадает в слоты при одиннадцатом событии?
Прибор собран, износ посчитан, чек-лист прогнан. Если посмотреть на скетч целиком, в нём нет ни одного нового приёма про память — и это лучший знак: read и write, update, побайтовая раскладка, структуры, ресурс и кольца из всего раздела сложились в двадцать байт карты, которые переживут и молнию, и детей с кнопками. Потоковые логи при этом увозят на SD-карту, как в уроке про SD-логирование, а ёмкости EEPROM хватает ровно потому, что туда попадает только то, что должно жить годами.
На этом раздел закрыт. Прибор, который помнит настройки годами, — история не про память, а про дисциплину записи: считайте циклы до запуска, держите карту адресов с хозяевами, проверяйте целостность и пишите по событию. Остальное — рутинный код, который теперь у вас есть.
Частые вопросы по теме урока
- Наработка пишется в EEPROM раз в час. На сколько хватит ячеек?
- 24 записи в сутки — 8760 в год: 100000/8760 дает около одиннадцати лет. Без буфера, каждую минуту, ячейки жили бы 69 дней. Около 11 лет
- Зачем копить минуты в RAM и сбрасывать в EEPROM раз в час?
- Буферизация — компромисс: частота записей падает в 60 раз, а цена — потеря текущего часа наработки при пропадании питания. 1440 записей в сутки убивают ячейку за 69 дней; буфер режет частоту до 24, а потеря при сбое — максимум час
- Что проверяет прибор при старте, чтобы отличить пустую плату, старую прошивку и порванную запись?
- Magic ловит чистую память, версия — смену раскладки между прошивками, сумма — мусор и разорванные записи. Все три причины ведут к заводским дефолтам. Тройку: magic, версию и XOR-сумму
- Почему заводской сброс я делаю командой с подтверждением, а не тумблером на панели?
- История с теплицей: задетый проводом тумблер раз в неделю стирал настройки. Сброс с подтверждением и событием 9 в журнале защищает от случайности. Тумблер задевают случайно: прибор молча теряет настройки и работает на дефолтах
- Журнал хранит 8 слотов. Что попадает в слоты при одиннадцатом событии?
- Курсор ходит по кругу от 0 до 7, каждое новое событие затирает самое старое. В памяти всегда последние восемь событий. Затирает самый старый слот по кругу