На подоконнике у меня зимовала рассада, и я собрал ей ночной подогрев: коробка, лампа на 40 ватт через релейный модуль, DHT11 сверху на скотче. Днём всё работало прекрасно. А ночью я услышал звук, от которого у любого ардуинщика холодеет в животе: реле начало стрекотать — щёлк-щёлк-щёлк, два-три щелчка в секунду, и так минутами. Лампа мигала, как на дискотеке, и, кажется, рассада была в шоке не меньше меня.
Код выглядел безупречно: если температура ниже 24 — включить нагрев, если выше — выключить. Именно эта «безупречность» и была багом. В этом уроке мы соберём термостат правильно: с гистерезисом, который убирает стрекот, с приборной панелью на LCD 16x2 и со звуковой тревогой при перегреве. Заодно поймём, почему одно и то же слово «термостат» в железе и в голове новичка означает разное.
Почему реле стрекочет: жизнь на границе
Смотрите, что происходит в течение одной секунды той ночи. Реальная температура в коробке стоит на 24,0 °C — ровно на пороге. Датчик шумит: одно чтение — 23,9, программа включает нагрев. Через секунду — 24,1, программа выключает. И так по кругу: чтение, щелчок, чтение, щелчок. Программа делает ровно то, что написано, — просто написано это для мира, где температура меняется плавно, а датчики не шумят. Оба допущения ложные.
Цена вопроса — в железе. Механическое реле — это пружинка и контакты, ресурс типового релейного модуля порядка 100 000 переключений. При двух щелчках в секунду вы сжигаете месяцы ресурса за одну ночь, а лампа и блок питания получают удары током, на которые не рассчитаны. Гистерезис убирает всю эту боль одним дополнительным сравнением — сейчас покажу каким.
Гистерезис: два порога вместо одного
Гистерезис — это когда порог включения и порог выключения разные. У нашего термостата для рассады нагрев включается, когда температура упала до 23,0 °C, и выключается, когда дошла до 24,0 °C. Между 23,0 и 24,0 — мёртвая зона: что бы ни показывал датчик, состояние реле не меняется. Ширина этого окна и называется гистерезисом; у нас — 1 °C, как в задании этого урока.
Теперь шум датчика не может ничего сломать: чтобы реле переключилось, температура должна пройти окно целиком, а не дёрнуться на границе. В комнате температура ползёт со скоростью примерно 0,1–0,5 °C в минуту, значит реле щёлкнет раз в несколько минут, а не два раза в секунду. За сезон набежит пара тысяч переключений вместо сотен тысяч — и ресурса хватит на годы.
Моё правило для ширины окна сформулировано с условием: чем грубее датчик и инертнее система, тем шире окно. Для комнаты с DHT11 ставлю 1 °C — его собственная точность ±2 °C всё равно грубее любого окна. Для инкубатора, где цена вопроса ±0,5 °C, берите DHT22 и окно 0,5–1 °C, иначе логика не поспеет за птенцом. Для инертного масляного радиатора окно можно расширять до 2 °C — он всё равно остывает медленно, и узкое окно ничего не даст, кроме лишних щелчков.
#include "DHT.h" DHT dht(2, DHT11); const float target = 24.0; // цель: держим 24 градусаconst float hyst = 1.0; // гистерезис 1 градусbool heaterOn = false; // состояние реле: живёт между циклами void setup() { pinMode(8, OUTPUT); Serial.begin(9600); dht.begin();} void loop() { float t = dht.readTemperature(); if (t > 100.0 || t < -40.0) { Serial.println("nan: сенсор не ответил, состояние не трогаем"); } else { // порог 1: стало холоднее цели на целый градус — включаем if (t < target - hyst) { heaterOn = true; } // порог 2: дотянули до цели — выключаем if (t > target) { heaterOn = false; } if (heaterOn) { digitalWrite(8, HIGH); } else { digitalWrite(8, LOW); } Serial.print("t = "); Serial.print(t); if (heaterOn) { Serial.println(", heater ON"); } else { Serial.println(", heater OFF"); } } delay(1000);}Ключевая тонкость спрятана в двух отдельных if, а не в конструкции if/else. Если написать «если холодно — включить, иначе — выключить», вы незаметно вернулись к одному порогу: ветка «иначе» сработает на любом значении выше 23, и гистерезис исчезнет. Порогов должно быть два, а переключение — только при пересечении одного из них.
Вторая тонкость — переменная heaterOn объявлена над setup(), а не внутри loop(). Она глобальная и живёт между проходами цикла, и в этом весь смысл: программа должна помнить, что она сделала в прошлый раз. Эта переменная и есть память термостата — «сейчас грею» или «сейчас жду». Запомните приём: хранить состояние между циклами и менять его только на событиях. Он же понадобится вам в кнопках с фиксацией и в конечных автоматах, до которых мы доберёмся в продвинутом разделе.
Приборная панель: LCD 16x2 и сирена при перегреве
Числа в Serial — для отладки, для жизни нужен экран. Возьмём LCD 16x2 на шине I2C: два провода данных и два питания, адрес обычно 0x27. Полное подключение экрана разобрано в нашем уроке про LiquidCrystal_I2C, здесь возьмём только четыре команды: lcd.init(), lcd.backlight(), lcd.setCursor(колонка, строка) и lcd.print(). Строки и колонки нумеруются с нуля: setCursor(0, 1) — начало второй строки.
Ловушка, о которую я ударился лично: если каждый цикл вызывать lcd.clear(), экран начинает заметно мерцать — очистка и перерисовка разделяются паузой, и глаз это ловит. Поэтому clear() зовём только в исключительной ситуации — например, при ошибке сенсора, — а обычное обновление делаем через setCursor(0, 0) и печать поверх старого текста с хвостовыми пробелами, затирающими остатки предыдущей строки.
Вторая ловушка — кириллица. Знакогенератор экрана 1602 хранит латиницу, и русские слова в lcd.print превратятся в абракадабру. Поэтому в коде ниже надписи намеренно на латинице: HEATER ON, SENSOR ERROR. Если захотите русские буквы — придётся создавать свои символы, у нас для этого отдельный материал в разделе про LCD.
#include "DHT.h"#include "LiquidCrystal_I2C.h" DHT dht(2, DHT11);LiquidCrystal_I2C lcd(0x27, 16, 2); const float target = 24.0; // цельconst float hyst = 1.0; // гистерезис 1 градусconst float overheat = 30.0; // аварийный порогbool heaterOn = false; void setup() { pinMode(8, OUTPUT); Serial.begin(9600); dht.begin(); lcd.init(); lcd.backlight(); lcd.setCursor(0, 0); lcd.print("Termostat ready");} void loop() { float t = dht.readTemperature(); if (t > 100.0 || t < -40.0) { lcd.clear(); lcd.setCursor(0, 0); lcd.print("SENSOR ERROR"); delay(1000); } else { // два порога: включаем ниже 23, выключаем выше 24 if (t < target - hyst) { heaterOn = true; } if (t > target) { heaterOn = false; } if (heaterOn) { digitalWrite(8, HIGH); } else { digitalWrite(8, LOW); } // сирена при перегреве, тишина в остальных случаях if (t > overheat) { tone(9, 1000); } else { noTone(9); } // обновляем панель без clear() — поверх старого текста lcd.setCursor(0, 0); lcd.print("T:"); lcd.print(t); lcd.print("C "); lcd.setCursor(0, 1); if (heaterOn) { lcd.print("HEATER ON "); } else { lcd.print("HEATER OFF "); } delay(1000); }}Про сирену отдельно, потому что у tone() есть характер. Вызов tone(9, 1000) запускает на пине 9 прямоугольные импульсы частотой 1000 Гц — пьезопищалка между пином и GND начинает свистеть. Звук длится, пока вы не вызовете noTone(9): поэтому в коде выше noTone стоит в ветке «всё хорошо», и это не украшение, а обязательная половина команды. Забыли noTone — пьезо запищит навсегда, и на настоящей плате вы узнаете об этом глубокой ночью.
Нюанс, который экономит час отладки: tone() на Uno занимает таймер Timer2, а он же управляет ШИМ на пинах 3 и 11 — analogWrite на них замолкает, пока звучит сирена. Нашей логике реле и LCD это безразлично, но если добавите ШИМ-подсветку и она внезапно «зависнет» на полной яркости — вы теперь знаете, откуда тишина.
Эксперименты для закрепления
Термостат — идеальный полигон: одну и ту же логику можно вывернуть наизнанку тремя способами, и все три ждут вас в симуляторе.
- Переверните логику: соберите охладитель для летней жары — вентилятор включается при t выше target + hyst и выключается при t ниже target. Подсказка: поменяйте местами два сравнения и смысл HIGH/LOW.
- Сделайте окно полем эксперимента: попробуйте hyst = 0.2 против hyst = 3.0. Покачайте ползунок вокруг порога и объясните себе, почему узкое окно заставляет светодиод мигать, как стрекотало то самое реле.
- Добавьте в Serial печать только при смене состояния: заведите bool lastState и печатайте строку, только если heaterOn отличается от lastState, не забыв обновить lastState. Это приём из больших проектов: лог по событию, а не по тиканию.
- Гистерезис — разные пороги включения и выключения; у нашего термостата окно 1 °C: включение при t < 23,0, выключение при t > 24,0.
- Состояние реле держим в глобальной bool heaterOn — она и есть память термостата, живущая между проходами loop().
- Два отдельных if вместо if/else: иначе второй порог тихо схлопнется в один, и стрекот вернётся.
- LCD 16x2 на I2C: init(), backlight(), setCursor(колонка, строка), print(); clear() только для ошибок — иначе экран мерцает.
- tone(9, 1000) свистит до noTone(9); на Uno tone занимает Timer2 и глушит ШИМ на пинах 3 и 11.
- 1. Что такое гистерезис термостата?
- 2. target = 22,0, гистерезис 1,0. При какой температуре включится реле обогревателя?
- 3. Почему конструкция «если t < 24 — включить, иначе — выключить» плоха для реле?
- 4. Что произойдёт, если вызвать tone(9, 1000) и забыть noTone(9)?
- 5. Зачем переменная heaterOn объявлена над setup(), а не внутри loop()?
- 6. Почему lcd.clear() не вызывают в каждом цикле?
У вас в руках настоящий термостат: он держит температуру, докладывает на экран и кричит при аварии — тот же каркас, что в инкубаторах, теплицах и холодильниках. Следующий шаг за пределы урока — вынести логику со светодиода на релейный модуль с оптопарой и добавить настройку цели кнопками. А дальше по курсу нас ждут сервоприводы и светодиодные ленты — но что бы вы ни собирали, проверка данных через if (t > 100.0 || t < -40.0) и два порога вместо одного уже будут вашим рефлексом.
Частые вопросы по теме урока
- Что такое гистерезис термостата?
- Гистерезис — это ширина мёртвой зоны между двумя порогами: внутри неё состояние реле не меняется, что бы ни показывал шумящий датчик. Разница между порогом включения и порогом выключения
- target = 22,0, гистерезис 1,0. При какой температуре включится реле обогревателя?
- Включение — когда t < target - hyst, то есть ниже 21,0. Выключение — когда t выше 22,0. Между 21 и 22 состояние не меняется. При 21,0 и ниже
- Почему конструкция «если t < 24 — включить, иначе — выключить» плоха для реле?
- Ветка «иначе» срабатывает на любом значении выше порога — гистерезис исчезает. Нужны два отдельных сравнения и переменная состояния. Получается один порог: шум датчика заставит реле щёлкать на границе
- Что произойдёт, если вызвать tone(9, 1000) и забыть noTone(9)?
- tone() генерирует частоту непрерывно, пока не встретит noTone(). Поэтому noTone стоит в ветке «всё хорошо» — это выключатель, а не ритуал. Пьезо будет свистеть, пока плата не выключится или пин не перенастроят
- Зачем переменная heaterOn объявлена над setup(), а не внутри loop()?
- Локальная переменная создаётся заново на каждом проходе loop() и забывает прошлое значение. Термостат без памяти о своём состоянии не бывает. Глобальная сохраняет значение между проходами loop() — это память термостата
- Почему lcd.clear() не вызывают в каждом цикле?
- Обновление делают через setCursor и печать поверх старого текста с хвостовыми пробелами, а clear() оставляют для исключений вроде ошибки сенсора. Очистка и перерисовка разделяются паузой — экран заметно мерцает