#include <Wire.h>
#include <EEPROM.h>
#include <RTClib.h>
#include <LiquidCrystal_I2C.h>
// Автополив и фитолампа на Arduino Nano.
//
// Три канала: фитолампа 230 В по расписанию и два насоса 12 В, каждый
// со своим периодом полива. Настройка — одной ручкой энкодера, экран
// LCD 16x2. Настройки живут в EEPROM и переживают выключение.
//
// Экран. Верхняя строка всегда одна: часы, температура, влажность.
// Нижняя — один из экранов, поворот ручки их листает:
//
// 20:00 23.5C 45% часы, температура и влажность (всегда)
// L 07:00-21:00 ON фитолампа: включить в 07:00, выключить в 21:00
// 1 100s 3d(2) 20h насос P1: качать 100 с, раз в 3 дня, осталось
// 2 дня (0 — сегодня), поливать в 20:00
// 2 60s 2d(1) 08h насос P2: 60 с раз в 2 дня, завтра в 08:00
//
// Во время полива после номера насоса стоит «>», а вместо длительности
// идёт обратный отсчёт.
//
// Нажатие на ручку — правка: мигает первое поле, поворот меняет его,
// следующее нажатие — следующее поле, после последнего всё сохраняется.
// Долгое нажатие во время правки — отмена. Долгое нажатие на экране
// насоса — полить сейчас (или остановить полив).
//
// Надписи латинские: в знакогенераторе HD44780 кириллицы нет.
//
// Часы через монитор порта: «time 19:59:50», «date 29.09.2026»,
// «status» — все настройки и состояние.
//
// На схеме: три выключателя каналов (sw1–sw3) стоят в силовой цепи
// последовательно с контактами реле, как тумблеры на корпусе. Щелчок по
// выключателю отключает канал, что бы ни решила прошивка. Температуру и
// влажность меняют в свойствах датчика ht1 прямо во время работы.
// --- выводы -----------------------------------------------------------
const byte ENC_A = 2, ENC_B = 3, ENC_KEY = 4; // энкодер S1, S2, KEY
const byte RELAY_LAMP = 5; // реле 1 — фитолампа
const byte RELAY_PUMP[] = { 6, 7 }; // реле 2 и 3 — насосы P1, P2
// Запасные реле модуля — выводы 8, 9, 10. Третий насос — это третий
// вывод в RELAY_PUMP и третья строка в DEFAULT_PUMPS: экран, настройка,
// EEPROM и полив подхватят его сами.
const byte PUMPS = sizeof(RELAY_PUMP);
// Уровень, которым модуль реле включает канал. Модуль в модели
// включается высоким уровнем. У шестиканальных модулей с оптронами
// (HL-56S и похожие) вход часто инверсный — включает LOW. Проверьте свой
// модуль и поменяйте здесь одну константу.
const byte RELAY_ON = HIGH;
const byte RELAY_OFF = RELAY_ON == HIGH ? LOW : HIGH;
const byte HTU21D = 0x40; // датчик на шине I2C
// --- настройки --------------------------------------------------------
struct Pump {
uint16_t seconds; // сколько секунд качать
byte period; // раз в сколько дней
byte left; // сколько дней осталось; 0 — сегодня
byte hour; // в котором часу
};
struct Settings {
byte magic;
uint16_t lampOn, lampOff; // минуты от полуночи
Pump pump[PUMPS];
uint16_t day; // номер дня последнего пересчёта (от 2000 г.)
};
// Нажатие на ручку. Объявлено здесь, выше всех функций: Arduino
// вставляет прототипы функций перед первой из них, и тип, объявленный
// ниже, в прототипе был бы ещё неизвестен.
enum Press { NONE, SHORT, LONG };
const byte MAGIC = 0xA8; // поменяйте — и EEPROM заполнится заново
const Pump DEFAULT_PUMPS[] = { { 100, 3, 2, 20 }, { 60, 2, 1, 8 } };
Settings cfg;
void loadSettings() {
EEPROM.get(0, cfg);
if (cfg.magic == MAGIC) return;
cfg.magic = MAGIC;
cfg.lampOn = 7 * 60;
cfg.lampOff = 21 * 60;
for (byte i = 0; i < PUMPS; i++) cfg.pump[i] = DEFAULT_PUMPS[i];
cfg.day = 0;
EEPROM.put(0, cfg);
}
// put пишет только изменившиеся байты: ресурс EEPROM — сто тысяч
// перезаписей ячейки, и сохранение раз в день его не тронет.
void saveSettings() { EEPROM.put(0, cfg); }
// --- железо -----------------------------------------------------------
RTC_DS3231 rtc;
LiquidCrystal_I2C lcd(0x27, 16, 2);
volatile int clicks = 0;
void onEncoder() {
if (digitalRead(ENC_B)) clicks++; else clicks--;
}
// HTU21D без библиотеки: команда «измерь», ожидание, три байта ответа —
// старший, младший и CRC-8. Два младших бита — служебные, их обнуляем.
bool htuRead(byte cmd, float &out) {
Wire.beginTransmission(HTU21D);
Wire.write(cmd);
if (Wire.endTransmission() != 0) return false;
delay(55); // измерение до 50 мс
if (Wire.requestFrom(HTU21D, (byte)3) != 3) return false;
byte msb = Wire.read(), lsb = Wire.read(), crc = Wire.read();
byte c = 0;
byte data[2] = { msb, lsb };
for (byte i = 0; i < 2; i++) {
c ^= data[i];
for (byte k = 0; k < 8; k++) c = (c & 0x80) ? (c << 1) ^ 0x31 : c << 1;
}
if (c != crc) return false;
uint16_t raw = ((uint16_t)msb << 8 | lsb) & 0xFFFC;
out = cmd == 0xF3 ? -46.85 + 175.72 * raw / 65536.0 : -6.0 + 125.0 * raw / 65536.0;
return true;
}
float temperature = NAN, humidity = NAN;
void readClimate() {
float t, h;
temperature = htuRead(0xF3, t) ? t : NAN;
humidity = htuRead(0xF5, h) ? constrain(h, 0, 100) : NAN;
}
// --- каналы -----------------------------------------------------------
bool lampLit = false;
bool running[PUMPS];
unsigned long startedAt[PUMPS];
bool lampShouldBeOn(uint16_t minute) {
if (cfg.lampOn == cfg.lampOff) return false;
if (cfg.lampOn < cfg.lampOff) return minute >= cfg.lampOn && minute < cfg.lampOff;
return minute >= cfg.lampOn || minute < cfg.lampOff; // через полночь
}
void startPump(byte i, const char *why) {
running[i] = true;
startedAt[i] = millis();
Serial.print(F("P")); Serial.print(i + 1);
Serial.print(F(": полив ")); Serial.print(cfg.pump[i].seconds);
Serial.print(F(" с (")); Serial.print(why); Serial.println(F(")"));
}
void stopPump(byte i) {
running[i] = false;
Serial.print(F("P")); Serial.print(i + 1); Serial.println(F(": полив закончен"));
}
// Смена суток: у каждого насоса «осталось дней» уменьшается. Номер дня
// хранится в EEPROM, поэтому пропущенные выключенным прибором сутки тоже
// засчитываются.
void newDay(uint16_t today) {
if (cfg.day != 0 && today > cfg.day) {
uint16_t passed = today - cfg.day;
for (byte i = 0; i < PUMPS; i++) {
cfg.pump[i].left = cfg.pump[i].left > passed ? cfg.pump[i].left - passed : 0;
}
}
cfg.day = today;
saveSettings();
}
void control(const DateTime &now, bool editing) {
uint16_t today = now.unixtime() / 86400UL - 10957; // дней с 2000 года
if (today != cfg.day) newDay(today);
bool lamp = lampShouldBeOn(now.hour() * 60 + now.minute());
if (lamp != lampLit) {
lampLit = lamp;
Serial.println(lamp ? F("лампа включена") : F("лампа выключена"));
}
digitalWrite(RELAY_LAMP, lampLit ? RELAY_ON : RELAY_OFF);
for (byte i = 0; i < PUMPS; i++) {
Pump &p = cfg.pump[i];
// Полив сегодня и час уже настал. «Уже», а не «ровно»: прибор,
// включённый в 20:30, польёт, а не пропустит день. Пока идёт правка,
// решение откладывается — значения ещё не окончательные.
if (!editing && !running[i] && p.left == 0 && now.hour() >= p.hour) {
startPump(i, "по расписанию");
p.left = p.period; // следующий — через period суток
saveSettings();
}
if (running[i] && millis() - startedAt[i] >= p.seconds * 1000UL) stopPump(i);
digitalWrite(RELAY_PUMP[i], running[i] ? RELAY_ON : RELAY_OFF);
}
}
// --- интерфейс --------------------------------------------------------
const byte SCREENS = 1 + PUMPS;
byte screen = 0;
int8_t field = -1; // -1 — просмотр, иначе номер правимого поля
unsigned long touchedAt = 0; // последнее действие человека
byte fieldsOf(byte s) { return s == 0 ? 2 : 4; }
// Правка поля на n щелчков. Пределы — в одном месте: здесь.
void adjust(int n) {
if (screen == 0) {
uint16_t &v = field == 0 ? cfg.lampOn : cfg.lampOff;
v = (v + 1440 + n * 10) % 1440; // шаг 10 минут, по кругу
return;
}
Pump &p = cfg.pump[screen - 1];
switch (field) {
case 0: p.seconds = constrain((int)p.seconds + n * 5, 5, 995); break;
case 1: p.period = constrain(p.period + n, 1, 9);
if (p.left > p.period) p.left = p.period; break;
case 2: p.left = constrain(p.left + n, 0, p.period); break;
case 3: p.hour = (p.hour + 24 + n) % 24; break;
}
}
// Где на экране лежит поле: начало и ширина — для мигания.
void fieldSpan(byte &at, byte &width) {
static const byte LAMP[][2] = { { 2, 5 }, { 8, 5 } };
static const byte PUMP[][2] = { { 2, 4 }, { 7, 2 }, { 9, 3 }, { 13, 3 } };
const byte *f = screen == 0 ? LAMP[field] : PUMP[field];
at = f[0]; width = f[1];
}
void drawTop(const DateTime &now) {
char t[6] = "--.-", h[4] = "--";
if (!isnan(temperature)) dtostrf(temperature, 4, 1, t);
if (!isnan(humidity)) itoa(min((int)(humidity + 0.5), 99), h, 10);
char line[24];
// dtostrf — потому что sprintf на AVR не печатает float.
snprintf(line, sizeof line, "%02d:%02d %5sC %2s%%", now.hour(), now.minute(), t, h);
line[16] = 0;
lcd.setCursor(0, 0);
lcd.print(line);
}
void drawBottom() {
char line[24]; // с запасом; на экран идут первые 16
if (screen == 0) {
snprintf(line, sizeof line, "L %02u:%02u-%02u:%02u %s",
cfg.lampOn / 60, cfg.lampOn % 60, cfg.lampOff / 60, cfg.lampOff % 60,
lampLit ? "ON" : "--");
} else {
byte i = screen - 1;
Pump &p = cfg.pump[i];
// Во время полива вместо длительности — сколько ещё осталось.
unsigned int s = running[i] ? p.seconds - (millis() - startedAt[i]) / 1000 : p.seconds;
snprintf(line, sizeof line, "%u%c%3us %ud(%u) %02uh",
i + 1, running[i] ? '>' : ' ', s, p.period, p.left, p.hour);
}
line[16] = 0;
// Правимое поле мигает: две трети времени видно, треть — пробелы.
if (field >= 0 && (millis() - touchedAt) > 600 && (millis() / 300) % 3 == 0) {
byte at, width;
fieldSpan(at, width);
for (byte k = 0; k < width; k++) line[at + k] = ' ';
}
lcd.setCursor(0, 1);
lcd.print(line);
for (byte k = strlen(line); k < 16; k++) lcd.print(' ');
}
// Кнопка ручки: короткое нажатие — по отпусканию, долгое — через
// секунду удержания, не дожидаясь отпускания.
Press readKey() {
static bool down = false, longFired = false;
static unsigned long since = 0;
bool now = digitalRead(ENC_KEY) == LOW;
if (now && !down) { down = true; longFired = false; since = millis(); }
if (now && down && !longFired && millis() - since >= 1000) { longFired = true; return LONG; }
if (!now && down) {
down = false;
if (!longFired && millis() - since >= 30) return SHORT; // 30 мс — дребезг
}
return NONE;
}
void handleInput() {
noInterrupts();
int n = clicks;
clicks = 0;
interrupts();
if (n) {
touchedAt = millis();
if (field < 0) screen = (screen + SCREENS + (n > 0 ? 1 : -1)) % SCREENS;
else adjust(n);
}
Press k = readKey();
if (k != NONE) touchedAt = millis();
if (k == SHORT) {
if (field < 0) field = 0;
else if (++field >= fieldsOf(screen)) {
field = -1;
saveSettings();
Serial.println(F("настройки сохранены"));
}
} else if (k == LONG) {
if (field >= 0) {
field = -1;
loadSettings(); // отмена: вернуть то, что в EEPROM
Serial.println(F("правка отменена"));
} else if (screen > 0) {
byte i = screen - 1;
if (running[i]) stopPump(i); else startPump(i, "вручную");
}
}
// Забытая правка через полминуты сохраняется сама: иначе полив
// так и стоял бы в ожидании её окончания.
if (field >= 0 && millis() - touchedAt > 30000UL) {
field = -1;
saveSettings();
}
}
// --- монитор порта ----------------------------------------------------
void printStatus(const DateTime &now) {
char buf[40];
snprintf(buf, sizeof buf, "%02d.%02d.%04d %02d:%02d:%02d", now.day(), now.month(), now.year(),
now.hour(), now.minute(), now.second());
Serial.println(buf);
snprintf(buf, sizeof buf, "лампа %02u:%02u-%02u:%02u %s", cfg.lampOn / 60, cfg.lampOn % 60,
cfg.lampOff / 60, cfg.lampOff % 60, lampLit ? "вкл" : "выкл");
Serial.println(buf);
for (byte i = 0; i < PUMPS; i++) {
Pump &p = cfg.pump[i];
snprintf(buf, sizeof buf, "P%u %us %ud(%u) %02uh%s", i + 1, p.seconds, p.period, p.left, p.hour,
running[i] ? " полив" : "");
Serial.println(buf);
}
}
void handleSerial() {
static char buf[24];
static byte len = 0;
while (Serial.available()) {
char c = Serial.read();
if (c != '\n' && c != '\r') {
if (len < sizeof buf - 1) buf[len++] = c;
continue;
}
if (!len) continue;
buf[len] = 0;
len = 0;
DateTime now = rtc.now();
int a, b, c2 = 0;
if (sscanf(buf, "time %d:%d:%d", &a, &b, &c2) >= 2) {
rtc.adjust(DateTime(now.year(), now.month(), now.day(), a, b, c2));
Serial.println(F("время установлено"));
} else if (sscanf(buf, "date %d.%d.%d", &a, &b, &c2) == 3) {
rtc.adjust(DateTime(c2, b, a, now.hour(), now.minute(), now.second()));
Serial.println(F("дата установлена"));
} else if (!strcmp(buf, "status")) {
printStatus(now);
} else {
Serial.println(F("команды: time ЧЧ:ММ[:СС], date ДД.ММ.ГГГГ, status"));
}
}
}
// --- setup и loop -----------------------------------------------------
void setup() {
Serial.begin(9600);
pinMode(RELAY_LAMP, OUTPUT);
digitalWrite(RELAY_LAMP, RELAY_OFF);
for (byte i = 0; i < PUMPS; i++) {
pinMode(RELAY_PUMP[i], OUTPUT);
digitalWrite(RELAY_PUMP[i], RELAY_OFF);
}
pinMode(ENC_A, INPUT); // подтяжка S1 и S2 — на плате энкодера
pinMode(ENC_B, INPUT);
pinMode(ENC_KEY, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(ENC_A), onEncoder, FALLING);
lcd.init();
lcd.backlight();
Wire.beginTransmission(HTU21D);
Wire.write(0xFE); // программный сброс датчика
Wire.endTransmission();
delay(15);
if (!rtc.begin()) Serial.println(F("DS3231 молчит: проверьте A4 и A5"));
if (rtc.lostPower()) rtc.adjust(DateTime(F(__DATE__), F(__TIME__)));
loadSettings();
readClimate();
Serial.println(F("автополив: готов, «status» — настройки"));
}
void loop() {
static unsigned long lastClimate = 0, lastDraw = 0;
handleInput();
handleSerial();
DateTime now = rtc.now();
control(now, field >= 0);
if (millis() - lastClimate >= 5000) {
lastClimate = millis();
readClimate();
}
if (millis() - lastDraw >= 100) {
lastDraw = millis();
drawTop(now);
drawBottom();
}
}
Автополив и фитолампа на Arduino Nano
Настоящее устройство для подоконника: фитолампа 230 В по расписанию и два насоса 12 В, каждый со своим периодом полива. Часы DS3231, датчик HTU21D, экран LCD 16x2 и одна ручка энкодера для всех настроек; настройки хранятся в EEPROM.
Что на схеме
Плата Arduino Nano, к ней подключены:
- Реле —
rl1 - Реле —
rl2 - Реле —
rl3 - Ввод 230 В —
in - Автоматический выключатель —
qf1 - Выключатель одноклавишный —
sw1 - Светильник 230 В —
la1, тип: импульсный - Блок питания 12 В —
ps - Тумблер —
sw2 - Тумблер —
sw3 - Мотор 12 В —
m1 - Мотор 12 В —
m2 - LCD 16x2 (I2C) —
lcd1 - Часы реального времени DS3231 (I2C) —
rtc1 - Датчик HTU21D: температура и влажность (I2C) —
ht1, влажность 45 %, температура 23.5 °C - Энкодер KY-040 (ручка с кнопкой) —
enc1
Задействованы выводы платы: 5V, GND, A4, A5, D2, D3, D4, D5, D6, D7. Всего соединений на схеме: 39.
Как это работает
Верхняя строка экрана не меняется: часы, температура и влажность с датчика HTU21D. Нижняя — три экрана, их листает поворот ручки:
L 07:00-21:00 ON— фитолампа: время включения и выключения, справа — горит ли она сейчас. Интервал может переходить через полночь (например,20:00-06:00); одинаковое время включения и выключения значит «не включать».1 100s 3d(2) 20h— насос P1: качать 100 секунд, раз в 3 дня, до полива осталось 2 дня, поливать в 20:00. Ноль в скобках — «сегодня».2 60s 2d(1) 08h— насос P2, то же самое.
Счётчик «осталось дней» уменьшается при смене суток. Номер последних суток лежит в EEPROM вместе с настройками, поэтому сутки, пропущенные выключенным прибором, тоже засчитываются. Когда счётчик дошёл до нуля и наступил час полива, реле насоса включается на заданное число секунд, а счётчик снова становится равен периоду. Прибор, включённый после часа полива, польёт сразу, а не пропустит день. Во время полива на экране насоса стоит > и идёт обратный отсчёт.
Настройка одной ручкой
В модели ручкой удобнее всего управлять с пульта: он появляется в правом нижнем углу схемы на время прогона, не зависит от масштаба схемы и показывает экран крупно. Щёлкните по пульту — и работают клавиши: ← → поворачивают ручку, пробел нажимает (удержание — долгое нажатие).
- Поворот — следующий экран.
- Нажатие — правка: начинает мигать первое поле, поворот меняет его, следующее нажатие переходит к следующему полю. После последнего поля настройки записываются в EEPROM.
- Долгое нажатие во время правки — отмена: вернуть то, что было записано.
- Долгое нажатие на экране насоса — полить сейчас или остановить полив. Удобно проверить насос и шланги.
- Правка, брошенная на полминуты, сохраняется сама — иначе полив так и ждал бы её окончания.
Часы DS3231 при первом запуске ставятся на время сборки прошивки. Точное время задаётся в мониторе порта: time 19:59:40, date 29.09.2026; status печатает все настройки.
Силовая часть
Фитолампа питается от сети: фаза через автомат qf1, выключатель канала sw1 и контакты реле rl1. Насосы — от блока питания 12 В, каждый через свой выключатель (sw2, sw3) и реле (rl2, rl3). Выключатели стоят последовательно с контактами реле, как тумблеры на корпусе: выключенный канал не заработает, что бы ни решила прошивка. Цепи 230 В и 12 В считаются раздельно, у каждой свой разбор на вкладке «Силовая часть».
У модуля шесть реле, заняты три; свободные — на выводах 8, 9 и 10. Третий насос — это третий вывод в RELAY_PUMP и третья строка в DEFAULT_PUMPS: экран, настройка, EEPROM и полив подхватят его сами.
Чем модель отличается от устройства
- Питание платы. В устройстве Nano питается от того же блока 12 В через понижающий преобразователь XL4015 на 5 В. В модели преобразователя нет, плата питается сама; общий минус с блоком 12 В на железе есть и допустим.
- Уровень включения реле. В модели реле включается высоким уровнем. Шестиканальные модули с оптронами (HL-56S и похожие) часто включаются низким — проверьте свой и поменяйте константу
RELAY_ON. - Автомат
qf1в модели добавлен: линию 230 В к лампе без защиты оставлять не стоит и в устройстве. - HTU21D в модели отвечает сразу; на железе измерение занимает до 50 мс, и скетч это время ждёт.
Библиотеки
Пример собирается с библиотеками:
- LiquidCrystal I2C
- RTClib
- Adafruit BusIO
Они уже установлены на сервере сборки — отмечать их в проекте вручную не нужно, список приезжает вместе с примером.
Как это устроено в коде
Основное, чем пользуется скетч:
EEPROM— энергонезависимая память микроконтроллера: 1 КБ у ATmega328P, 4 КБ у ATmega2560RTClib— работа с часами реального времениLiquidCrystal_I2C— текстовый экран 16×2 по двум проводамDS3231— точные часы с собственным термокомпенсированным генераторомdigitalRead()— читает уровень на выводеdelay()— пауза, на время которой ядро занято ожиданиемconstrain()— зажимает значение в границахmillis()— миллисекунды с запуска: на них строят работу без остановкиSerial.println()— печатает строку в монитор портаdigitalWrite()— выставляет на выводе высокий или низкий уровеньSerial.begin()— открывает монитор порта на заданной скоростиpinMode()— назначает выводу режим: выход, вход или вход с подтяжкой
В скетче 422 строки; свои функции: loadSettings(), onEncoder(), htuRead(), readClimate(), lampShouldBeOn(), startPump(), stopPump(), newDay(); подключено заголовков: 4. Сам скетч открыт на соседней вкладке — его можно менять и запускать заново.
Что попробовать изменить
- Нажмите «Запустить»: сценарий сам поставит часы на 19:59:40, переведёт ручкой насос P1 на «полить сегодня» и дождётся 20:00 — насос на схеме закрутится. Журнал — на вкладке «Сценарий»; после проверок прибор работает дальше.
- Управляйте прибором с пульта в правом нижнем углу схемы: там крупная копия экрана и кнопки ↺ ● ↻. Щёлкните по пульту — и крутите ручку клавишами ← →, а пробел нажимает её (держите — долгое нажатие).
- Во время полива щёлкните по выключателю
sw2на схеме: насос встанет, хотя реле по-прежнему включено, — так работает тумблер канала. - Поменяйте температуру и влажность датчика
ht1в его свойствах, не останавливая прогон: верхняя строка экрана обновится через пять секунд. - Наберите в мониторе порта
time 20:59:50и посмотрите, как в 21:00 гаснет фитолампа, а на вкладке «Силовая часть» пропадает её ток. - Добавьте третий насос: вывод 8 в
RELAY_PUMP, строку вDEFAULT_PUMPS, на схеме — реле на D8, выключатель и мотор 12 В от того же блока.
Смотрите также
- Подключение Модуль реле — Реле для управления нагрузкой
- Подключение LCD 1602 с I2C — Символьный дисплей 16×2
- Подключение DS3231 — Модуль часов реального времени
- Подключение KY-040 — Энкодер с кнопкой
- Сейф с кодом: клавиатура, сервозамок и OLED — Код набирается на клавиатуре, «#» — ввод, «*» — стереть. Верный код отводит ригель сервоприводом на пять секунд. Три оши…
- Метеостанция с тенденцией давления — Часы, температура, влажность и давление на одном экране — и стрелка, куда идёт давление за последний час. Три устройства…
- Кодовый замок с блокировкой — Клавиатура, экран и реле электрозамка. Код звёздочками, три ошибки подряд — блокировка на полминуты, смена кода с подтве…
- Будильник на DS3231 и энкодере — Время на индикаторе TM1637, будильник настраивается одной ручкой: нажатие — часы, минуты, выход; долгое нажатие — включи…
- Змейка на OLED и энкодере — Классическая змейка на экране 128×64: ручка поворачивает направо и налево, нажатие — пауза. Змейка хранится кольцом по б…
- Термостат с гистерезисом — DHT22 меряет, реле греет, ручка задаёт уставку. Гистерезис в полградуса и выдержка в десять секунд берегут реле. Темпера…
Сборка ещё не запускалась.
Печатайте в Serial числа — они станут графиком.
Serial.println(t); — один ряд,
Serial.printf("t:%.1f h:%.1f\n", t, h); — два с подписями.
Формат тот же, что понимает плоттер Arduino IDE.