Термометр в теплице у меня жил давно: крупные цифры с DS18B20 на OLED показывали 12,8 °C красиво, точно и совершенно бесполезно — цифра смотрела на меня, а рассада мёрзла. Термостат отличается одним словом: он держит уставку. Включает нагрев, когда холоднее уставки, выключает, когда догрел, и на экране появляются две новые сущности: уставка, которую крутят кнопками не глядя в код, и состояние — NAGREV или POKOJ крупным шрифтом, которое читается с другого конца теплицы.
Гистерезис — то, что отличает регулятор от мультивибратора. Моя первая версия сравнивала abs(t − уставка) с порогом и щёлкала реле каждые пару секунд у границы: за месяц контакты подгорели, щелчок стало слышно через стену, а температура так и гуляла в окне шума датчика. Рабочая схема — две границы с памятью: включаем нагрев ниже уставки минус четверть градуса, выключаем выше уставки плюс четверть. Окно в полградуса, и реле вместо сотни щелчков в час делает два-три. Где именно проваливается abs() и почему четыре строки кода решают всё — разберём построчно.
Железо: DS18B20 на D2 с подтяжкой 4,7 кОм — подключение разбирал в уроке про 1-Wire; модуль реле на D7; две кнопки уставки на D3 и D4. Отдельного разговора стоит питание: катушка реле тянет порядка 70 миллиампер, и если кормить её с 5 В платы вместе с датчиком и экраном, в момент втягивания просадка роняет и датчик, и дисплей. Как это выглядит и как лечится — рамка с числами ниже.
Уставка на экране и крупное состояние
Уставка крутится двумя кнопками с шагом полградуса: плюс на D3, минус на D4, окно анти-дребезга 300 мс. Быстрая серия кликов уводит уставку на несколько шагов — это не промах, а фича: подстроить с двух нажатий быстрее, чем ждать сонную кнопку. Значение живёт в переменной float и печатается в шапке рядом с фактом: T 23.6 / UST 24.0. Границы окна считаются на лету — niz = ust − 0.25, verh = ust + 0.25 — и обе печатаются мелкой строкой в подвале кадра: весь расклад виден без заглядывания в код, а настройка окна сводится к правке одной константы.
Состояние — size 2 по центру: NAGREV или POKOJ. Словами, а не иконкой, потому что слово читается через комнату без фокусировки, иконку приходится узнавать. Транслитом, а не кириллицей, потому что базовый шрифт Adafruit GFX не содержит кириллических знакомест: на экране появятся кракозябры, и лечится это либо переходом на U8g2 со своими шрифтами, либо транслитом — мой разбор обеих дорог в уроке про кириллицу и U8g2. Строка меняется пару раз в час, поэтому полный кадр на смене состояния — не расточительство: двадцать посылок в сутки шину не будят.
Датчик читается по окну раз в секунду. requestTemperatures() блокирует loop на 750 мс — это паспорт конверсии DS18B20 при 12-битном разрешении, а не ленивый код: быстрее нельзя, можно только грубее, снизив разрешение до 11 бит и сэкономив 375 мс. Слепая зона конверсии означает, что нажатие кнопки короче 750 мс может пролететь между окнами; на практике палец держит кнопку дольше, но если вам нужна кнопка боевого дежурства, её место на прерывании — там слепые зоны не страшны.
| Схема регулирования | Поведение реле | Контакты | Вердикт |
|---|---|---|---|
| один порог: t ниже уставки — вкл, иначе выкл | щелчок каждые 1-3 секунды у границы | подгорают за месяц | заготовка, а не регулятор: без памяти флага |
| abs(t − ust) с зоной полградуса | звенит у границы: направление сравнения теряется | устают медленнее, но всё равно | ловушка: выглядит разумно, работает дёргано |
| две границы и флаг состояния | 2-3 щелчка в час | живут годами | мой выбор: окно 0,5 °C, четыре строки кода |
| ПИД вместо гистерезиса | плавно, без колебаний | не щёлкают вообще | когда окно шире комфортного коридора не хочется |
Две границы вместо abs()
Код гистерезиса — четыре строки, и в них вся наука: if (t < niz) heater = 1; if (t > verh) heater = 0; digitalWrite(RELE, heater == 1 ? HIGH : LOW). Между границами ни одна ветка не срабатывает, и флаг heater держит прежнее состояние — это и есть память регулятора, которой был лишён мой первый вариант. abs() памятет ничего: на каждом замере он кричит «вне зоны», но молчит, в какую сторону, и логика «включать, если холоднее» у границы мечется вместе с шумом датчика в десятые градуса. Две границы отсекают мечетание геометрически: между niz и verh не происходит ничего, сколько бы шум ни пингал.
Число 0,5 °C — компромисс, а не магия. Снизу окно должно быть шире шума: DS18B20 обещает ±0,5 °C в диапазоне −10…+85 °C, а после сверки с эталонным термометром обычно врёт в пределах ±0,1-0,2 °C стабильно — окно в полградуса накрывает и заводской разброс, и остаточную ошибку. Сверху окно должно быть уже комфортного коридора: в теплице я держу плюс-минус градус, в аквариуме на той же логике — окно в градус, и рыба не замечает. Сделаете окно 2 °C — температура гуляет, рассада заметит; сделаете 0,1 °C — вернётесь к звенящему реле. Счёт щелчков, который убедил меня в этом окончательно, я вёл в уроке про реле и гистерезис.
Вторую жизнь этому каркасу дал аквариум: та же пара границ, другой датчик и другие константы — и аквариумный термостат заработал за вечер, как в уроке про DS18B20 и аквариум. Каркас безразличен к тому, что он греет: воздух, воду, пол в мастерской. Меняются константы окна и направление — охладитель включается выше верхней границы и выключается ниже нижней, логика зеркальная, четыре строки те же.
#include <Wire.h>#include <OneWire.h>#include <DallasTemperature.h>#include <Adafruit_GFX.h>#include <Adafruit_SSD1306.h> Adafruit_SSD1306 display(128, 64, &Wire, -1);OneWire oneWire(2); // DS18B20 на D2, подтяжка 4,7 кОмDallasTemperature sensors(&oneWire); const int KN_PLUS = 3; // уставка +const int KN_MINUS = 4; // уставка -const int RELE = 7; // модуль реле (активный HIGH) const float GIST = 0.25; // половина окна: окно 0,5 °Cfloat ust = 24.0; // уставкаfloat t = 0;int heater = 0; // 0 — покой, 1 — нагрев (память флага)unsigned long tChtenie = 0;unsigned long tKn = 0;int knP = HIGH, knM = HIGH; void setup() { Serial.begin(9600); pinMode(RELE, OUTPUT); pinMode(KN_PLUS, INPUT_PULLUP); pinMode(KN_MINUS, INPUT_PULLUP); digitalWrite(RELE, LOW); // при старте нагрев выключен sensors.begin(); display.begin(SSD1306_SWITCHCAPVCC, 0x3C); display.setTextColor(SSD1306_WHITE); Wire.setClock(400000);} void loop() { // уставка: фронт кнопки + окно 300 мс на серию кликов int p = digitalRead(KN_PLUS); int m = digitalRead(KN_MINUS); if (((p == LOW && knP == HIGH) || (m == LOW && knM == HIGH)) && millis() - tKn > 300) { tKn = millis(); if (p == LOW) ust = ust + 0.5; if (m == LOW) ust = ust - 0.5; Serial.print("ust="); Serial.println(ust, 1); } knP = p; knM = m; // чтение датчика по окну 1 с (конверсия 12 бит честно длится 750 мс) if (millis() - tChtenie >= 1000) { tChtenie = millis(); sensors.requestTemperatures(); float tt = sensors.getTempCByIndex(0); if (tt > -100) t = tt; // -127 — шина молчит: данные не трогаем float niz = ust - GIST; // включение нагрева float verh = ust + GIST; // выключение нагрева if (t < niz) heater = 1; if (t > verh) heater = 0; digitalWrite(RELE, heater == 1 ? HIGH : LOW); display.clearDisplay(); display.setTextSize(1); display.setCursor(0, 0); display.print("T " + String(t, 1) + " UST " + String(ust, 1)); display.setTextSize(2); display.setCursor(0, 18); if (heater == 1) display.print("NAGREV"); else display.print("POKOJ"); display.setTextSize(1); display.setCursor(0, 42); display.print("VKL < " + String(niz, 2) + " VYKL > " + String(verh, 2)); display.display(); Serial.print("t="); Serial.print(t, 1); Serial.print(" ten="); Serial.println(heater); }}Две вещи в скетче стоят комментария. Первая — строка digitalWrite(RELE, LOW) в setup: без неё пин до первого решения висит в воздухе, и модуль реле с активным LOW мог бы щёлкнуть при подаче питания — у многих модулей вход инвертирован, и это надо проверять до монтажа в шкаф; раскладку входов и оптопару я разбирал в уроке про модуль реле и 220 В. Вторая — проверка tt > -100: минус 127 у DS18B20 означает не мороз, а молчание шины, и принять его за настоящую температуру значило бы выключить нагрев посреди зимы.
Питание я развожу так: реле получает свои 5 В от отдельного источника или от винтовой клеммы блока питания, JD-VCC-перемычка на модуле снята, у датчика — конденсатор 470 мкФ рядом с проводами. Плата в этой схеме только командует: её 5 В питают экран и датчик, но не катушку. Обещанная рамка с числами — ниже: это тот случай, когда вечер, потраченный на перемычку, экономит неделю чтения логов.
В симуляторе датчик живой: ползунок от −55 до +125 с шагом в полградуса, и getTempCByIndex отдаёт ровно то, что вы выставили, с лёгким шумом, как настоящий. Проведите столбик вниз через нижнюю границу — реле щёлкнет, экран покажет NAGREV; теперь гоняйте столбик между границами — щелчков не будет, сколько бы шум ни толкал цифру. Это и есть окно вживую: до первой границы флаг стоит, после неё включается нагрев, и вернуть всё обратно можно только переехав верхнюю границу.
Кнопки уставки работают и в симуляторе: зажмите кнопку на компоненте, и уставка уползёт на шаг. Конверсия занимает 750 миллисекунд — нажатие короче может пролететь между окнами, на реальной плате это ровно та же слепая зона, о которой шла речь выше. Счётчик щелчков реле в симуляторе лучше всего смотреть в Serial-логе: строки ten=1 и ten=0 чередуются только на переходах через границы, и это готовый тест на честность гистерезиса без секундомера.
- 1. Почему реле щёлкало каждые пару секунд в первой версии с abs()?
- 2. Что делает флаг heater, пока температура гуляет между границами окна?
- 3. При каком окне гистерезиса реле снова зазвенит у границы?
- 4. Почему на экране POKOJ, а не ПОКОЙ?
- 5. После щелчка реле датчик вернул −127. Что это чаще всего значит?
- Термостат = термометр + уставка + состояние: уставка крутится кнопками с шагом 0,5 °C, состояние — size 2, видно через комнату
- Гистерезис — две границы и память флага: вкл ниже ust − 0,25, выкл выше ust + 0,25, между границами реле держит что было
- abs(t − ust) не годится: он сообщает «вне зоны», но не направление — у границы реле звенит вместе с шумом датчика
- Конверсия DS18B20 блокирует loop на 750 мс: окно чтения 1 с, кнопки живут в остатке, −127 — это нет данных, а не мороз
- Катушка реле тянет порядка 70 мА: общая 5 В с датчиком и экраном даёт просадку и враньё датчика — JD-VCC и отдельное питание
Частые вопросы по теме урока
- Почему реле щёлкало каждые пару секунд в первой версии с abs()?
- abs() кричит «вне зоны» на каждом замере, но молчит о направлении: у границы решение переворачивается вместе с шумом датчика, и реле звенит. abs() не помнит состояние и даёт команду на каждом замере у границы
- Что делает флаг heater, пока температура гуляет между границами окна?
- Память флага — вся суть гистерезиса: между niz и verh не происходит ничего, и реле щёлкает только на переходах через границы. Держит прежнее состояние: ни одна ветка не срабатывает
- При каком окне гистерезиса реле снова зазвенит у границы?
- Окно обязано быть шире шума измерения: 0,1 °C внутри заводской погрешности ±0,5 °C — решения будут переворачиваться от каждого выброса. 0,1 °C — окно уже шума датчика
- Почему на экране POKOJ, а не ПОКОЙ?
- Встроенный шрифт GFX — латиница; кириллица приходит либо из U8g2 со своими шрифтами, либо из транслита — разбор в уроке про кириллицу. Базовый шрифт Adafruit GFX не содержит кириллических знакомест
- После щелчка реле датчик вернул −127. Что это чаще всего значит?
- −127 — молчание шины. Катушка тянет порядка 70 мА при втягивании, и общая 5 В просаживается ниже горизонта работы датчика. Просадка питания от катушки реле, живущей с датчиком на одной 5 В