#include <WiFi.h>
#include <Preferences.h>
#include <LittleFS.h>
#include <OneWire.h>
#include <DallasTemperature.h>
#include <AccelStepper.h>
#include <PubSubClient.h>
#include <SettingsESP.h>
// Тепловой насос вода–вода на ESP32, версия 2: контроллер и веб-панель на
// Settings.
//
// Установка собирается вокруг внешнего блока кондиционера «старт–стоп»
// (компрессор без инвертора): скважина отдаёт тепло в испаритель,
// конденсатор греет воду тёплого пола, дроссель — электронный
// расширительный вентиль (ЭРВ) на шаговом двигателе. Плата
//
// - читает 11 термометров DS18B20 по трём линиям 1-Wire (фреон — 4 датчика
// на D4, скважина и улица — 3 на D5, тёплый пол, помещение и корпус
// компрессора — 4 на D23);
// - читает два датчика давления фреона (низкое D35, высокое D34) и ток
// компрессора (D39);
// - по давлению и таблице фреона считает температуры кипения и
// конденсации, перегрев на всасывании и переохлаждение жидкости;
// - управляет четырьмя реле: компрессор, насос скважины, насос тёплого
// пола и «авария» (лампа, сирена или вход котла);
// - держит перегрев ПИ-регулятором, двигая ЭРВ шаговым двигателем;
// - охраняет установку: низкое и высокое давление, температура
// нагнетания, ток компрессора, промерзание испарителя, нет протока,
// пропал датчик, сработала аппаратная защита;
// - показывает всё на странице Settings (кнопка «Веб-интерфейс») и
// отдельными значениями публикует в MQTT.
//
// Что нового по сравнению с версией 1 (по замечаниям пользователей):
// 1. Стартовая позиция ЭРВ — из таблицы «температура воды из скважины на
// входе (tc1) → шаги» с поправкой на обратку пола (th1), а не константа
// 220. Значения по умолчанию считаются по формуле расхода через вентиль,
// дальше их подстраивают руками или самообучением (см. LEARN_K).
// 2. Шаг ПИ масштабируется по текущему открытию: Kp и Ki умножаются на
// (открытие / piRef)^piPow. Включается флагом, чтобы сравнить со
// старым поведением.
// 3. Счётчик пусков компрессора за час, всего и длительность последнего
// цикла — индикатор короткой цикличности и оценки буфера: если насос
// стартует чаще предела, объёма воды в контуре не хватает.
// 4. Вход «аппаратная защита сработала» (оптрон на цепи HP, термостатов и
// реле протока): если цепь разомкнулась, в журнале — почему.
// 5. Защита по току компрессора: слишком малый (обрыв, сухой ход) и
// слишком большой; первые секунды после пуска не проверяются.
// 6. MQTT: состояние, перегрев, переохлаждение, положение ЭРВ, аварии,
// ток, пуски и температуры — отдельными темами.
// 7. Погодозависимость — по желанию: наклон или кривая по точкам
// «улица → подача». По умолчанию выключена: у скважины тепло не
// зависит от погоды, а нужная подача задаёт сам дом.
// 8. Один фреон для сборки на железе (REAL_BUILD, REFRIGERANT_DEFAULT) и
// обновление по воздуху (USE_OTA).
//
// Что на схеме в симуляторе, чего нет на железе и наоборот:
// - ЭРВ подключён к плате прямо: для симуляции этого достаточно, для
// железа нужен драйвер — ULN2003/ULN2803 для униполярного ЭРВ на 12 В,
// L298N или DRV8833 для биполярного. Порядок выводов D18, D19, D21,
// D22 — как в AccelStepper(FULL4WIRE, 1, 2, 3, 4).
// - датчики давления — потенциометры на 3,3 В. Настоящие датчики
// (0,5–4,5 В на 5 В питания) вешают на АЦП через делитель 10к/20к;
// коэффициент делителя, диапазон шкалы и калибровка — в панели.
// - ток компрессора — тоже потенциометр: 0…3,3 В означает 0…curRange А.
// На железе вместо него — трансформатор тока с выпрямителем и делителем
// либо счётчик PZEM-004T по UART; для PZEM достаточно заменить
// readCurrent(), остальное не меняется. PZEM в симуляторе не
// эмулируется.
// - датчики протока — движки переключателей: замкнут на землю — вода
// течёт. Настоящие — герконовые или лопастные, контакт на землю.
// - вход цепи защит (D16) — тоже движок переключателя: замкнут на землю —
// цепь HP, термостатов и реле протока цела. На железе — выход оптрона
// (PC817 или модуль на 230 В), вход которого стоит после последнего
// контакта цепи, перед катушкой контактора.
// - подтяжка 4,7 кОм на линиях 1-Wire в симуляторе есть всегда, на
// железе — по резистору между DATA и 3V3 на каждую из трёх линий.
//
// Времена по умолчанию сокращены (SIM_DEMO = 1), чтобы сценарий
// проходил за минуты. Для установки поставьте 0: компрессору будет
// нужно 5 минут паузы между пусками, насосам — минута выбега.
#define SIM_DEMO 1
#define REAL_BUILD 0 // 1 — в прошивке один фреон, лишние таблицы и выбор в панели убираются
#define REFRIGERANT_DEFAULT 1 // 0 R22, 1 R410A, 2 R134a, 3 R407C, 4 R404A, 5 R290, 6 R600a
#define USE_OTA 0 // 1 — обновление по Wi-Fi (ArduinoOTA); в симуляторе не проверяется
#if USE_OTA
#include <ArduinoOTA.h>
#endif
// --- сеть -------------------------------------------------------------
const char *SSID = "PinPort-GUEST", *PASSWORD = "";
const char *AP_SSID = "HeatPump", *AP_PASSWORD = "heatpump1";
const char *ADMIN_PASSWORD = ""; // пусто — страница без пароля
// Брокер MQTT. В модели любой адрес ведёт на встроенный брокер PinPort
// (вкладка «MQTT»); на железе — адрес Mosquitto или брокера Home
// Assistant, логин и пароль пользователя MQTT.
const char *BROKER = "broker.hivemq.com";
const uint16_t BROKER_PORT = 1883;
const char *MQTT_USER = "", *MQTT_PASSWORD = "";
const char *MQTT_BASE = "pinport/heatpump/water";
// --- выводы -----------------------------------------------------------
const byte LINES = 3;
const byte PIN_OW[LINES] = { 4, 5, 23 }; // линии 1-Wire: фреон, скважина и улица, пол
const byte PIN_P_HIGH = 34, PIN_P_LOW = 35; // датчики давления (только вход, АЦП1)
const byte PIN_CUR = 39; // ток компрессора (только вход, АЦП1)
const byte PIN_FLOW_COLD = 14, PIN_FLOW_HOT = 27; // протоки: LOW — течёт
const byte PIN_CHAIN = 16; // цепь защит (оптрон): LOW — замкнута
const byte PIN_BUTTON = 13; // кнопка: пуск/стоп, долго — сброс аварии
const byte PIN_EEV[4] = { 18, 19, 21, 22 }; // ЭРВ: A+, A-, B+, B-
const byte PIN_LED = 2; // встроенный светодиод: часто мигает при аварии
// Реле: на плате AC-DC-ESP32-Relay-x4 они включаются высоким уровнем.
const byte R_COMP = 25, R_COLD = 26, R_HOT = 32, R_ALARM = 33;
const byte RELAY_ON = HIGH;
const byte RELAY_OFF = RELAY_ON == HIGH ? LOW : HIGH;
// --- датчики температуры ----------------------------------------------
enum { T_SUCTION, T_DISCH, T_LIQUID, T_EVAPIN,
T_COLD_IN, T_COLD_OUT, T_OUTDOOR,
T_HOT_IN, T_HOT_OUT, T_ROOM, T_COMP, SENSORS };
struct SensorInfo { const char *name; const char *key; byte line; };
const SensorInfo SENSOR[SENSORS] = {
{ "Фреон: всасывание (выход испарителя)", "suction", 0 },
{ "Фреон: нагнетание (выход компрессора)", "discharge", 0 },
{ "Фреон: жидкость (выход конденсатора)", "liquid", 0 },
{ "Фреон: вход испарителя (после ЭРВ)", "evap_in", 0 },
{ "Скважина: вход испарителя", "well_in", 1 },
{ "Скважина: выход испарителя", "well_out", 1 },
{ "Улица", "outdoor", 1 },
{ "Пол: обратка (вход конденсатора)", "return", 2 },
{ "Пол: подача (выход конденсатора)", "supply", 2 },
{ "Помещение", "room", 2 },
{ "Корпус компрессора", "compressor", 2 },
};
// Адреса датчиков на первом запуске. На настоящей установке их
// заменяют своими: страница показывает, что нашлось на каждой линии, и
// адрес вписывается в поле роли. Пустой адрес — роль не назначена.
const char *DEFAULT_ADDR[SENSORS] = {
"280B65BE03AA7EAB",
"280F96A602D6392E",
"2844C32858F45713",
"28139EE317E80A85",
"2880AB7EC084E0A4",
"284C27E812B9C716",
"2826BB5233B7EDB8",
"286FC27D61D870B5",
"28572B9AB180D675",
"2870A72D6B48101D",
"282E6AC8E1A0102D",
};
// Состояния и аварии. Объявлены выше всех функций: Arduino вставляет
// прототипы перед первой из них, и типам в них надо уже существовать.
enum State : uint8_t { S_OFF, S_PUMPS, S_RUN, S_COOL, S_FAULT };
enum Fault : uint8_t { F_NONE, F_SENSOR, F_PSENSOR, F_LOW, F_HIGH, F_DISCH, F_FREEZE, F_FLOW_COLD, F_FLOW_HOT,
F_HWPROT, F_CUR_LOW, F_CUR_HIGH };
// --- фреоны -----------------------------------------------------------
// Давление насыщения, бар (абсолютное) × 100, при температуре от −35 до
// +70 °C с шагом 5 градусов. Таблицы — из прежнего проекта, между узлами
// линейная интерполяция. Для смесей (R407C, R404A) — среднее между
// кипением и конденсацией: у них есть температурный глайд.
#define ROW_0 { 132, 164, 201, 245, 296, 354, 421, 498, 584, 681, 789, 910, 1044, 1192, 1355, 1534, 1729, 1942, 2174, 2427, 2700, 2996 } // R22
#define ROW_1 { 219, 270, 331, 401, 482, 575, 681, 801, 936, 1088, 1258, 1448, 1657, 1889, 2145, 2426, 2734, 3071, 3440, 3843, 4283, 4670 } // R410A
#define ROW_2 { 66, 85, 107, 133, 164, 201, 243, 293, 350, 414, 488, 572, 665, 770, 887, 1016, 1160, 1318, 1491, 1681, 1889, 2116 } // R134a
#define ROW_3 { 108, 137, 171, 211, 259, 314, 378, 452, 536, 633, 742, 864, 1002, 1156, 1327, 1517, 1728, 1960, 2215, 2496, 2804, 3140 } // R407C
#define ROW_4 { 166, 204, 250, 303, 364, 433, 513, 603, 704, 818, 945, 1086, 1243, 1416, 1606, 1816, 2045, 2295, 2568, 2866, 3189, 3540 } // R404A
#define ROW_5 { 136, 166, 202, 242, 289, 342, 403, 471, 547, 633, 727, 832, 948, 1075, 1214, 1366, 1532, 1711, 1907, 2118, 2346, 2593 } // R290 (пропан)
#define ROW_6 { 37, 47, 59, 73, 89, 109, 132, 158, 188, 222, 261, 304, 353, 408, 469, 536, 610, 692, 781, 879, 986, 1102 } // R600a (изобутан)
#define ROW_CAT(a, b) a##b
#define ROW_PICK(a, b) ROW_CAT(a, b)
const byte PT_N = 22;
const int PT_T0 = -35, PT_STEP = 5;
#if REAL_BUILD
// Один фреон: остальные таблицы не занимают флеш, а выбор в панели скрыт.
const byte FREONS = 1;
const char *FREON_NAME = "";
const uint16_t PT[FREONS][PT_N] = { ROW_PICK(ROW_, REFRIGERANT_DEFAULT) };
#else
const byte FREONS = 7;
const char *FREON_NAME = "R22;R410A;R134a;R407C;R404A;R290;R600a";
const uint16_t PT[FREONS][PT_N] = { ROW_0, ROW_1, ROW_2, ROW_3, ROW_4, ROW_5, ROW_6 };
#endif
// Температура насыщения по абсолютному давлению, бар.
float satTemp(byte f, float pAbs) {
const uint16_t *t = PT[f];
float p = pAbs * 100.0f;
if (p <= t[0]) return PT_T0;
for (byte i = 1; i < PT_N; i++) {
if (p <= t[i]) return PT_T0 + PT_STEP * (i - 1 + (p - t[i - 1]) / (float)(t[i] - t[i - 1]));
}
return PT_T0 + PT_STEP * (PT_N - 1);
}
// Обратное: давление насыщения (абсолютное, бар) по температуре.
float satPress(byte f, float tC) {
const uint16_t *p = PT[f];
float x = (tC - PT_T0) / (float)PT_STEP;
if (x <= 0) return p[0] / 100.0f;
if (x >= PT_N - 1) return p[PT_N - 1] / 100.0f;
byte i = (byte)x;
return (p[i] + (p[i + 1] - p[i]) * (x - i)) / 100.0f;
}
// --- настройки --------------------------------------------------------
const byte SP_N = 5; // точек в таблице стартовой позиции ЭРВ
const byte CV_N = 4; // точек в отопительной кривой
struct Config {
uint8_t version;
bool enabled; // установка включена
uint8_t mode; // 0 ручной, 1 по помещению, 2 по подаче, 3 по обратке
uint8_t freon; // номер в таблице
float target, hyst; // целевая температура и ширина гистерезиса, °C
float wcSlope; // погодозависимость: на сколько градусов поднять цель на каждый градус мороза (0 — выкл.)
bool curveOn; // вместо наклона — кривая по точкам
float curveT[CV_N], curveV[CV_N]; // улица → цель подачи, °C
float minLow, maxHigh; // пороги давления (избыточного), бар
float maxDisch; // предел температуры нагнетания и корпуса, °C
float minColdOut; // предел температуры воды на выходе испарителя, °C
float loRange, hiRange; // шкалы датчиков давления, бар
float divider; // делитель перед АЦП: напряжение датчика / напряжение на входе
uint16_t tBoot, tPumpBefore, tPumpAfter, tMinOff, tMinRun, tFlow, tRetry, tGrace, tEev;
uint8_t retries; // сколько раз плата сама пробует снова после аварии
float shTarget, kp, ki; // перегрев и коэффициенты ПИ
uint16_t tLowCharge; // предупреждение о фреоне: ЭРВ на максимуме дольше, с (0 — выключено)
float lowChargeDelta; // и перегрев выше цели больше чем на, К
uint16_t eevMin, eevMax, eevStart, eevPre; // eevStart — и константа без таблицы, и опора формулы
// Масштабирование шага ПИ по открытию вентиля.
bool piScale;
uint16_t piRef; // открытие, при котором Kp и Ki равны заданным
float piPow; // 1 — пропорция, меньше — мягче, больше — круче
// Стартовая позиция ЭРВ.
bool useTable; // false — всегда eevStart, как в версии 1
float spKey[SP_N]; // ключ: температура воды из скважины на входе (tc1), °C
uint16_t spPos[SP_N]; // шагов
float spRetK, spRetRef; // поправка: шагов на градус обратки (th1) выше spRetRef
bool learn; // самообучение таблицы
uint16_t learnTime; // сколько секунд перегрев должен держаться у цели
// Пуски, цепь защит, ток.
uint8_t maxStarts; // предупреждать, если пусков за час больше (0 — не предупреждать)
bool hwUse;
bool curUse;
float curRange, curMin, curMax; // шкала датчика и пределы, А
uint16_t tCurGrace; // с после пуска ток не проверяем
bool mqttOn;
char addr[SENSORS][17]; // адреса датчиков, 16 шестнадцатеричных цифр
};
const uint8_t VERSION = 1; // поменяйте — и настройки заполнятся заново
const float LEARN_K = 0.2f; // самообучение: новое = старое·(1 − K) + замер·K
const float EVAP_BELOW_SRC = 6.0f, COND_ABOVE_RETURN = 8.0f; // допущения формулы, К
Config cfg;
Preferences prefs;
// Расход через вентиль растёт с перепадом давления, а нужен он такой же,
// как расход компрессора, который растёт с давлением на всасывании. Для
// опоры «кипение 0 °C, конденсация 40 °C → eevStart»:
// шагов = eevStart · (Pкип / Pкип0) · √((Pконд0 − Pкип0) / (Pконд − Pкип))
// Кипение берётся на 6 К ниже воды из скважины, конденсация — на 8 К выше
// обратки пола. Это первое приближение, а не паспорт вентиля: настоящая
// характеристика ЭРВ нелинейна, поэтому таблицу после заполнения подстраивают.
float formulaPos(float tEvap, float tCond) {
float pe = satPress(cfg.freon, tEvap), pc = satPress(cfg.freon, tCond);
float pe0 = satPress(cfg.freon, 0), pc0 = satPress(cfg.freon, 40);
if (pc < pe + 0.5f) pc = pe + 0.5f;
return cfg.eevStart * (pe / pe0) * sqrtf((pc0 - pe0) / (pc - pe));
}
void fillTable() {
for (byte i = 0; i < SP_N; i++) {
float p = formulaPos(cfg.spKey[i] - EVAP_BELOW_SRC, cfg.spRetRef + COND_ABOVE_RETURN);
cfg.spPos[i] = (uint16_t)constrain(lroundf(p), (long)cfg.eevMin, (long)cfg.eevMax);
}
// Поправка на обратку — наклон той же формулы в средней точке таблицы.
float te = cfg.spKey[SP_N / 2] - EVAP_BELOW_SRC;
float hi = formulaPos(te, cfg.spRetRef + COND_ABOVE_RETURN + 5);
float lo = formulaPos(te, cfg.spRetRef + COND_ABOVE_RETURN - 5);
cfg.spRetK = (hi - lo) / 10.0f;
}
void setDefaults() {
memset(&cfg, 0, sizeof cfg);
cfg.version = VERSION;
cfg.enabled = false;
cfg.mode = 0;
cfg.freon = REAL_BUILD ? 0 : REFRIGERANT_DEFAULT; // R410A: в кондиционерах чаще всего он
cfg.target = 30; cfg.hyst = 2;
cfg.wcSlope = 0; // погодозависимость выключена
cfg.curveOn = false;
const float ct[CV_N] = { -20, -10, 0, 10 }, cv[CV_N] = { 38, 34, 31, 28 };
for (byte i = 0; i < CV_N; i++) { cfg.curveT[i] = ct[i]; cfg.curveV[i] = cv[i]; }
cfg.minLow = 2.0; cfg.maxHigh = 35.0;
cfg.maxDisch = 110; cfg.minColdOut = 2.0;
cfg.loRange = 17.5; cfg.hiRange = 45.0;
cfg.divider = 1.5;
#if SIM_DEMO
cfg.tBoot = 8; cfg.tPumpBefore = 6; cfg.tPumpAfter = 6; cfg.tMinOff = 15;
cfg.tMinRun = 12; cfg.tFlow = 8; cfg.tRetry = 30; cfg.tGrace = 15; cfg.tEev = 12;
cfg.learnTime = 30; cfg.tCurGrace = 5;
#else
cfg.tBoot = 300; cfg.tPumpBefore = 60; cfg.tPumpAfter = 60; cfg.tMinOff = 300;
cfg.tMinRun = 180; cfg.tFlow = 15; cfg.tRetry = 600; cfg.tGrace = 60; cfg.tEev = 60;
cfg.learnTime = 600; cfg.tCurGrace = 5;
#endif
cfg.retries = 3;
cfg.shTarget = 5; cfg.kp = 4; cfg.ki = 0.6;
cfg.tLowCharge = SIM_DEMO ? 20 : 300; cfg.lowChargeDelta = 4;
cfg.eevMin = 66; cfg.eevMax = 480; cfg.eevStart = 220; cfg.eevPre = 480;
cfg.piScale = true; cfg.piRef = 250; cfg.piPow = 1;
cfg.useTable = true;
const float sk[SP_N] = { 2, 5, 8, 11, 14 };
for (byte i = 0; i < SP_N; i++) cfg.spKey[i] = sk[i];
cfg.spRetRef = 30;
fillTable();
cfg.learn = true;
cfg.maxStarts = 6;
cfg.hwUse = true;
cfg.curUse = true; cfg.curRange = 10; cfg.curMin = 1.5; cfg.curMax = 8.5;
cfg.mqttOn = true;
for (byte i = 0; i < SENSORS; i++) strncpy(cfg.addr[i], DEFAULT_ADDR[i], 16);
}
// Таблицы держатся по возрастанию ключа: интерполяция иначе не работает.
void sortTables() {
for (byte i = 1; i < SP_N; i++)
for (byte j = i; j > 0 && cfg.spKey[j - 1] > cfg.spKey[j]; j--) {
float k = cfg.spKey[j]; cfg.spKey[j] = cfg.spKey[j - 1]; cfg.spKey[j - 1] = k;
uint16_t p = cfg.spPos[j]; cfg.spPos[j] = cfg.spPos[j - 1]; cfg.spPos[j - 1] = p;
}
for (byte i = 1; i < CV_N; i++)
for (byte j = i; j > 0 && cfg.curveT[j - 1] > cfg.curveT[j]; j--) {
float t = cfg.curveT[j]; cfg.curveT[j] = cfg.curveT[j - 1]; cfg.curveT[j - 1] = t;
float v = cfg.curveV[j]; cfg.curveV[j] = cfg.curveV[j - 1]; cfg.curveV[j - 1] = v;
}
}
uint32_t startsTotal = 0; // пусков компрессора за всё время
bool dirtyConfig = false; // таблица подстроена самообучением и не записана
bool dirtyStarts = false; // счётчик пусков не записан
void saveSettings() {
prefs.begin("hp-water2", false);
prefs.putBytes("cfg", &cfg, sizeof cfg);
prefs.end();
dirtyConfig = false;
}
void loadSettings() {
prefs.begin("hp-water2", false);
size_t got = prefs.getBytes("cfg", &cfg, sizeof cfg);
startsTotal = prefs.getUInt("starts", 0);
prefs.end();
if (got == sizeof cfg && cfg.version == VERSION && cfg.freon < FREONS) { sortTables(); return; }
setDefaults();
saveSettings();
}
// Запись на флеш — только при остановке компрессора, а не по ходу работы:
// самообучение и счётчик пусков копятся в памяти.
void persistIfDirty() {
if (dirtyConfig) saveSettings();
if (dirtyStarts) {
prefs.begin("hp-water2", false);
prefs.putUInt("starts", startsTotal);
prefs.end();
dirtyStarts = false;
}
}
// --- журнал -----------------------------------------------------------
sets::Logger journal(1800);
void note(const String &line) {
uint32_t s = millis() / 1000;
char stamp[16];
snprintf(stamp, sizeof stamp, "%02lu:%02lu:%02lu ", s / 3600, s / 60 % 60, s % 60);
journal.print(stamp);
journal.println(line);
Serial.println(line);
}
// --- состояние установки ------------------------------------------------
const char *STATE_NAME[] = { "стоп", "запуск насосов", "работа", "выбег насосов", "АВАРИЯ" };
const char *STATE_ID[] = { "off", "pumps", "run", "cool", "fault" };
const char *FAULT_NAME[] = {
"нет", "пропал датчик температуры", "неисправен датчик давления", "низкое давление",
"высокое давление", "перегрев нагнетания", "промерзание испарителя",
"нет протока скважины", "нет протока тёплого пола",
"сработала аппаратная защита (цепь разомкнута)",
"ток компрессора слишком мал (обрыв, сухой ход)", "ток компрессора слишком велик",
};
const char *FAULT_ID[] = { "none", "sensor", "psensor", "low", "high", "disch", "freeze", "flow_cold", "flow_hot",
"hwprot", "cur_low", "cur_high" };
State state = S_OFF;
Fault fault = F_NONE;
uint32_t stateAt = 0, stopAt = 0, faultAt = 0;
bool stoppedOnce = false; // после первого останова действует пауза между пусками
uint8_t retryCount = 0;
bool heat = false; // решение гистерезиса: греть
bool eevHomed = false;
bool relayOn[4] = { false, false, false, false }; // компрессор, скважина, пол, авария
float temp[SENSORS]; // NAN — датчика нет или не отвечает
byte fails[SENSORS];
float pLow = NAN, pHigh = NAN; // избыточное давление, бар
float tEvap = NAN, tCond = NAN, superheat = NAN, subcool = NAN;
float amps = NAN; // ток компрессора, А
bool flowCold = false, flowHot = false;
bool chainOk = false; // цепь защит цела
// Пуски компрессора: метки времени последних, всего — в startsTotal.
const byte START_LOG = 24;
uint32_t startAt[START_LOG];
byte startHead = 0, startCnt = 0;
uint32_t compOnAt = 0; // когда компрессор включён в последний раз
uint32_t lastRun = 0, lastOff = 0; // длительность последней работы и паузы перед ней, с
uint16_t startsHour() {
uint16_t n = 0;
uint32_t now = millis();
for (byte i = 0; i < startCnt; i++)
if (now - startAt[i] < 3600000UL) n++;
return n;
}
void registerStart() {
uint32_t now = millis();
lastOff = stoppedOnce ? (now - stopAt) / 1000 : 0;
compOnAt = now;
startAt[startHead] = now;
startHead = (startHead + 1) % START_LOG;
if (startCnt < START_LOG) startCnt++;
startsTotal++;
dirtyStarts = true;
uint16_t n = startsHour();
if (cfg.maxStarts && n > cfg.maxStarts)
note(String("пусков за час: ") + n + " (предел " + cfg.maxStarts + ") — частая цикличность, проверьте объём воды в контуре");
}
void setRelay(byte i, byte pin, bool on) {
if (relayOn[i] == on) return;
relayOn[i] = on;
digitalWrite(pin, on ? RELAY_ON : RELAY_OFF);
if (i == 0) {
if (on) registerStart();
else lastRun = (millis() - compOnAt) / 1000;
}
}
String fmt(float v, byte dec = 1) { return isnan(v) ? String("—") : String(v, (unsigned int)dec); }
// --- датчики температуры ------------------------------------------------
OneWire ow0(PIN_OW[0]), ow1(PIN_OW[1]), ow2(PIN_OW[2]);
DallasTemperature dt0(&ow0), dt1(&ow1), dt2(&ow2);
DallasTemperature *dt[LINES] = { &dt0, &dt1, &dt2 };
const byte MAX_FOUND = 6;
DeviceAddress found[LINES][MAX_FOUND];
float foundTemp[LINES][MAX_FOUND];
byte foundCount[LINES];
DeviceAddress addr[SENSORS];
bool haveAddr[SENSORS];
String addrText(const uint8_t *a) {
char s[17];
for (byte i = 0; i < 8; i++) sprintf(s + i * 2, "%02X", a[i]);
return s;
}
bool parseAddr(const char *text, uint8_t *out) {
if (strlen(text) != 16) return false;
for (byte i = 0; i < 8; i++) {
char h[3] = { text[i * 2], text[i * 2 + 1], 0 };
char *end;
long v = strtol(h, &end, 16);
if (*end) return false;
out[i] = v;
}
return true;
}
void bindAddresses() {
for (byte i = 0; i < SENSORS; i++) {
haveAddr[i] = parseAddr(cfg.addr[i], addr[i]);
temp[i] = NAN;
fails[i] = 0;
}
}
void scanLines() {
for (byte l = 0; l < LINES; l++) {
dt[l]->begin();
dt[l]->setWaitForConversion(false);
dt[l]->setResolution(12);
foundCount[l] = min((int)dt[l]->getDeviceCount(), (int)MAX_FOUND);
for (byte i = 0; i < foundCount[l]; i++) {
dt[l]->getAddress(found[l][i], i);
foundTemp[l][i] = NAN;
}
Serial.printf("линия %u (D%u): датчиков %u\n", l + 1, PIN_OW[l], foundCount[l]);
}
}
// Раз в три секунды всем линиям — «измерь», через 0,8 с — читаем. Ждать
// измерения внутри библиотеки нельзя: за 0,75 с ЭРВ и страница стояли бы.
void readTemps() {
static uint8_t phase = 0;
static uint32_t t0 = 0;
uint32_t now = millis();
if (phase == 0) {
if (now - t0 < 3000) return;
for (byte l = 0; l < LINES; l++) dt[l]->requestTemperatures();
t0 = now;
phase = 1;
return;
}
if (now - t0 < 800) return;
phase = 0;
t0 = now;
for (byte i = 0; i < SENSORS; i++) {
if (!haveAddr[i]) { temp[i] = NAN; continue; }
float t = dt[SENSOR[i].line]->getTempC(addr[i]);
// −127 — датчик не ответил; +85 — то, что лежит в памяти до первого
// измерения, то есть измерение не состоялось.
if (t <= -100 || t == 85.0f) {
if (fails[i] < 250) fails[i]++;
if (fails[i] >= 3) temp[i] = NAN;
} else {
fails[i] = 0;
temp[i] = t;
}
}
for (byte l = 0; l < LINES; l++)
for (byte i = 0; i < foundCount[l]; i++) {
float t = dt[l]->getTempC(found[l][i]);
foundTemp[l][i] = t <= -100 ? NAN : t;
}
}
// --- давление и ток -----------------------------------------------------
// Датчик 0,5–4,5 В на шкалу 0…range бар. Меньше 0,3 В — обрыв или
// замыкание на землю, а не «ноль бар».
float readGauge(byte pin, float range) {
long sum = 0;
for (byte i = 0; i < 8; i++) sum += analogRead(pin);
float v = sum / 8.0f / 4095.0f * 3.3f * cfg.divider;
if (v < 0.3f) return NAN;
float p = (v - 0.5f) / 4.0f * range;
return constrain(p, 0.0f, range);
}
void readPressure() {
static uint32_t t0 = 0;
if (millis() - t0 < 250) return;
t0 = millis();
float lo = readGauge(PIN_P_LOW, cfg.loRange), hi = readGauge(PIN_P_HIGH, cfg.hiRange);
// Сглаживание: одно значение шума не должно бросать ЭРВ.
pLow = isnan(lo) ? NAN : (isnan(pLow) ? lo : pLow + 0.4f * (lo - pLow));
pHigh = isnan(hi) ? NAN : (isnan(pHigh) ? hi : pHigh + 0.4f * (hi - pHigh));
tEvap = isnan(pLow) ? NAN : satTemp(cfg.freon, pLow + 1.013f);
tCond = isnan(pHigh) ? NAN : satTemp(cfg.freon, pHigh + 1.013f);
superheat = (isnan(tEvap) || isnan(temp[T_SUCTION])) ? NAN : temp[T_SUCTION] - tEvap;
subcool = (isnan(tCond) || isnan(temp[T_LIQUID])) ? NAN : tCond - temp[T_LIQUID];
}
// Ток компрессора. Здесь — датчик с выходом 0…3,3 В на шкалу 0…curRange А
// (в симуляторе потенциометр). На железе замените тело функции чтением
// PZEM-004T или трансформатора тока: остальной скетч берёт значение
// только из переменной amps.
void readCurrent() {
static uint32_t t0 = 0;
if (millis() - t0 < 250) return;
t0 = millis();
long sum = 0;
for (byte i = 0; i < 8; i++) sum += analogRead(PIN_CUR);
float a = sum / 8.0f / 4095.0f * cfg.curRange;
amps = isnan(amps) ? a : amps + 0.4f * (a - amps);
}
// Дискретные входы: контакт на землю, три одинаковых отсчёта подряд.
struct Din {
byte pin;
bool low;
byte n;
uint32_t t0;
void step() {
if (millis() - t0 < 100) return;
t0 = millis();
n = digitalRead(pin) == LOW ? min(n + 1, 5) : 0;
low = n >= 3;
}
};
Din inFlowC = { PIN_FLOW_COLD, false, 0, 0 }, inFlowH = { PIN_FLOW_HOT, false, 0, 0 }, inChain = { PIN_CHAIN, false, 0, 0 };
void readInputs() {
inFlowC.step();
inFlowH.step();
inChain.step();
flowCold = inFlowC.low;
flowHot = inFlowH.low;
chainOk = inChain.low;
}
// --- ЭРВ --------------------------------------------------------------
AccelStepper eev(AccelStepper::FULL4WIRE, PIN_EEV[0], PIN_EEV[1], PIN_EEV[2], PIN_EEV[3]);
const float EEV_SPEED = 100, EEV_ACCEL = 300; // шагов/с и шагов/с²
float eevTarget = 0;
// Положения ЭРВ на старте неизвестны. Самое надёжное — считать, что он
// открыт полностью, и вести к нулю с запасом: упор не даст ему
// закрыться дальше, а шаги пройдут вхолостую.
void eevHome() {
eevHomed = false;
eev.setCurrentPosition(cfg.eevMax + 20);
eev.moveTo(0);
}
void eevClose() {
eev.setCurrentPosition(eev.currentPosition() + 20); // запас на упор
eev.moveTo(0);
eevTarget = 0;
}
void eevRun() {
static uint32_t idleAt = 0;
eev.run();
if (eev.distanceToGo() != 0) { idleAt = millis(); return; }
if (!eevHomed && eev.currentPosition() == 0) {
eevHomed = true;
note("ЭРВ закрыт, положение 0");
}
// Обмотки без движения не держим: они греются, а вентиль и так стоит.
if (millis() - idleAt > 3000 && idleAt) { eev.disableOutputs(); idleAt = 0; }
}
// --- стартовая позиция ЭРВ ----------------------------------------------
// Где ключ в таблице: узел i и вес w узла i + 1 (0…1). За краями таблицы
// значение не экстраполируется, а держится на крайнем узле.
void locate(float key, byte &i, float &w) {
if (key <= cfg.spKey[0]) { i = 0; w = 0; return; }
if (key >= cfg.spKey[SP_N - 1]) { i = SP_N - 2; w = 1; return; }
for (i = 0; i < SP_N - 2 && key > cfg.spKey[i + 1]; i++) {}
float span = cfg.spKey[i + 1] - cfg.spKey[i];
w = span > 0 ? (key - cfg.spKey[i]) / span : 0;
}
// Положение вентиля сразу после пуска компрессора. Ключ — температура воды
// из скважины на входе испарителя: она определяет давление кипения.
float startPos() {
if (!cfg.useTable || isnan(temp[T_COLD_IN])) return cfg.eevStart;
byte i;
float w;
locate(temp[T_COLD_IN], i, w);
float p = cfg.spPos[i] * (1 - w) + cfg.spPos[i + 1] * w;
if (!isnan(temp[T_HOT_IN])) p += cfg.spRetK * (temp[T_HOT_IN] - cfg.spRetRef);
return constrain(p, (float)cfg.eevMin, (float)cfg.eevMax);
}
// Самообучение: перегрев держался у цели (±1 К) дольше learnTime секунд —
// значит, текущее открытие подходит для этих условий. Таблица сдвигается к
// нему на LEARN_K от расхождения, узлы — пропорционально весам. Запись на
// флеш — при остановке (persistIfDirty).
uint16_t learned = 0;
void learnApply(float meas) {
if (!cfg.useTable || isnan(temp[T_COLD_IN])) return;
byte i;
float w;
locate(temp[T_COLD_IN], i, w);
float err = meas - startPos();
float a = cfg.spPos[i] + LEARN_K * (1 - w) * err;
float b = cfg.spPos[i + 1] + LEARN_K * w * err;
cfg.spPos[i] = (uint16_t)constrain(lroundf(a), (long)cfg.eevMin, (long)cfg.eevMax);
cfg.spPos[i + 1] = (uint16_t)constrain(lroundf(b), (long)cfg.eevMin, (long)cfg.eevMax);
learned++;
dirtyConfig = true;
note(String("самообучение: при ") + fmt(temp[T_COLD_IN]) + " °C воды из скважины держится " + lroundf(meas) +
" шагов, таблица подстроена (узлы " + (i + 1) + "–" + (i + 2) + ")");
}
void learnStep(float err) {
static uint32_t since = 0;
static float sum = 0;
static uint32_t n = 0;
if (!cfg.learn || !cfg.useTable || fabsf(err) > 1.0f) { since = 0; return; }
if (!since) { since = millis(); sum = 0; n = 0; }
sum += eevTarget;
n++;
if (millis() - since >= cfg.learnTime * 1000UL) {
learnApply(sum / n);
since = 0;
}
}
// Масштаб шага ПИ. Расход через вентиль нелинеен: при малом открытии шаг в
// несколько шагов двигателя меняет расход сильнее, чем при большом.
float piFactor() {
if (!cfg.piScale) return 1.0f;
float f = powf(max(eevTarget, 1.0f) / max((float)cfg.piRef, 1.0f), cfg.piPow);
return constrain(f, 0.25f, 3.0f);
}
// Нехватка фреона или засорённый фильтр: ЭРВ уже открыт на максимум, а перегрев
// всё равно выше цели. Это не авария — установка продолжает работать, — но в
// журнале, на панели и в MQTT появляется предупреждение. Без него такое
// состояние выглядит как «всё штатно»: ПИ просто упёрся в предел.
bool lowCharge = false;
uint32_t lowChargeSince = 0;
void chargeReset() { lowChargeSince = 0; lowCharge = false; }
void chargeWatch() {
bool bad = cfg.tLowCharge > 0 && !isnan(superheat) && eevTarget >= cfg.eevMax - 1 && superheat > cfg.shTarget + cfg.lowChargeDelta;
if (!bad) {
lowChargeSince = 0;
if (lowCharge && !isnan(superheat) && superheat < cfg.shTarget + 1) {
lowCharge = false;
note("перегрев вернулся к норме — предупреждение о фреоне снято");
}
return;
}
if (!lowChargeSince) lowChargeSince = millis();
if (!lowCharge && millis() - lowChargeSince >= cfg.tLowCharge * 1000UL) {
lowCharge = true;
note(String("ЭРВ на максимуме, перегрев ") + fmt(superheat) + " К при цели " + fmt(cfg.shTarget) + " К" + (isnan(subcool) ? String("") : String(", переохлаждение ") + fmt(subcool) + " К") + " — возможна нехватка фреона или засор фильтра");
}
}
String warnText() { return lowCharge ? String("нехватка фреона или засор фильтра") : String("нет"); }
// Скоростной ПИ: приращение шагов = kp·(ошибка − прошлая) + ki·ошибка·dt,
// kp и ki умножены на piFactor(). Перегрев выше нужного — открываем,
// ниже — прикрываем.
void eevControl() {
static uint32_t last = 0;
static float prevErr = 0;
const uint32_t PERIOD = 5000;
if (state != S_RUN || millis() - stateAt < cfg.tEev * 1000UL) { prevErr = NAN; learnStep(99); chargeReset(); return; }
if (millis() - last < PERIOD) return;
last = millis();
if (isnan(superheat)) { learnStep(99); return; }
float err = superheat - cfg.shTarget;
if (isnan(prevErr)) prevErr = err;
float f = piFactor();
eevTarget += cfg.kp * f * (err - prevErr) + cfg.ki * f * err * (PERIOD / 1000.0f);
prevErr = err;
eevTarget = constrain(eevTarget, (float)cfg.eevMin, (float)cfg.eevMax);
eev.moveTo(lroundf(eevTarget));
learnStep(err);
chargeWatch();
// В журнал — только заметные сдвиги, иначе он за минуту забит ЭРВ.
static float noted = 0;
if (fabsf(eevTarget - noted) >= 8) {
noted = eevTarget;
note(String("ЭРВ: ") + lroundf(eevTarget) + " шагов, перегрев " + fmt(superheat) + " К");
}
}
// --- защиты и управление ------------------------------------------------
// Условие должно держаться заданное время, иначе это выброс.
struct Hold {
uint32_t since = 0;
bool step(bool cond, uint32_t ms) {
if (!cond) { since = 0; return false; }
if (!since) since = millis();
return millis() - since >= ms;
}
};
Hold hLow, hHigh, hDisch, hFreeze, hFlowC, hFlowH, hSens, hPSens, hChain, hChainIdle, hCurLow, hCurHigh;
bool tempsCritical() {
return !isnan(temp[T_SUCTION]) && !isnan(temp[T_DISCH]) && !isnan(temp[T_LIQUID]) &&
!isnan(temp[T_COLD_OUT]);
}
// Кривая «улица → цель подачи» по точкам; за краями — значение крайней.
float curveValue(float t) {
if (t <= cfg.curveT[0]) return cfg.curveV[0];
if (t >= cfg.curveT[CV_N - 1]) return cfg.curveV[CV_N - 1];
byte i = 0;
while (i < CV_N - 2 && t > cfg.curveT[i + 1]) i++;
float span = cfg.curveT[i + 1] - cfg.curveT[i];
float w = span > 0 ? (t - cfg.curveT[i]) / span : 0;
return cfg.curveV[i] * (1 - w) + cfg.curveV[i + 1] * w;
}
// Цель подачи с поправкой на погоду. Наклон: чем холоднее на улице, тем
// горячее вода (цель задана для 0 °C; наклон 0 отключает поправку). Или
// кривая по точкам. Датчика улицы нет — цель без поправки.
float targetNow() {
if (isnan(temp[T_OUTDOOR])) return cfg.target;
if (cfg.curveOn) return curveValue(temp[T_OUTDOOR]);
if (cfg.wcSlope <= 0) return cfg.target;
return cfg.target - cfg.wcSlope * temp[T_OUTDOOR];
}
bool demand() {
if (!cfg.enabled) { heat = false; return false; }
if (cfg.mode == 0) { heat = true; return true; }
// Помещение держим по заданной цели, погодная поправка — только для воды.
float v = cfg.mode == 1 ? temp[T_ROOM] : cfg.mode == 2 ? temp[T_HOT_OUT] : temp[T_HOT_IN];
if (isnan(v)) return heat; // датчика нет — держим прежнее решение, а о датчике скажет защита
float t = cfg.mode == 1 ? cfg.target : targetNow();
if (v < t - cfg.hyst / 2) heat = true;
else if (v > t + cfg.hyst / 2) heat = false;
return heat;
}
bool recoverable(Fault f) {
return f == F_LOW || f == F_HIGH || f == F_FREEZE || f == F_FLOW_COLD || f == F_FLOW_HOT;
}
void enter(State s) { state = s; stateAt = millis(); }
void raise(Fault f) {
if (state == S_FAULT) return;
fault = f;
faultAt = millis();
setRelay(0, R_COMP, false);
stopAt = millis();
stoppedOnce = true;
eevClose();
enter(S_FAULT);
persistIfDirty();
note(String("АВАРИЯ: ") + FAULT_NAME[f] +
(recoverable(f) && retryCount < cfg.retries ? String(" — повтор через ") + cfg.tRetry + " с" : String(" — нужен сброс")));
}
void clearFault(const char *who) {
if (state != S_FAULT) return;
fault = F_NONE;
retryCount = 0;
enter(S_OFF);
note(String("авария сброшена (") + who + ")");
}
void emergencyStop() {
cfg.enabled = false;
saveSettings();
if (state == S_RUN || state == S_PUMPS) {
setRelay(0, R_COMP, false);
stopAt = millis();
stoppedOnce = true;
eevClose();
enter(S_COOL);
}
persistIfDirty();
note("аварийный стоп");
}
// Что видят датчики в момент, когда разомкнулась аппаратная цепь: так по
// журналу можно понять, какое реле или термостат её разорвало.
String causeText() {
String s;
if (!isnan(pHigh) && pHigh > cfg.maxHigh * 0.9f)
s += String("высокое давление ") + fmt(pHigh) + " бар (порог " + fmt(cfg.maxHigh) + "); ";
if (relayOn[0] && millis() - compOnAt > cfg.tGrace * 1000UL && !isnan(pLow) && pLow < cfg.minLow * 1.5f)
s += String("низкое давление ") + fmt(pLow) + " бар (порог " + fmt(cfg.minLow) + "); ";
if (!isnan(temp[T_DISCH]) && temp[T_DISCH] > cfg.maxDisch - 10)
s += String("нагнетание ") + fmt(temp[T_DISCH]) + " °C; ";
if (!flowCold) s += "нет протока скважины; ";
if (!flowHot) s += "нет протока тёплого пола; ";
if (!isnan(temp[T_COLD_OUT]) && temp[T_COLD_OUT] < cfg.minColdOut + 1)
s += String("выход испарителя ") + fmt(temp[T_COLD_OUT]) + " °C, близко к промерзанию; ";
if (!s.length()) return String("по датчикам причина не видна — смотрите реле HP и LP, термостаты, контактор");
return String("по датчикам: ") + s.substring(0, s.length() - 2); // без последнего «; »
}
void raiseChain(const char *when) {
String why = causeText(); // до остановки: после неё реле и давления уже другие
raise(F_HWPROT);
note(String("цепь защит разомкнута (") + when + "); " + why);
}
// Проток пропал: в журнал — где и сколько компрессор успел проработать.
void flowLost(Fault f, const char *where) {
uint32_t ran = relayOn[0] ? (millis() - compOnAt) / 1000 : 0;
note(String("проток ") + (f == F_FLOW_COLD ? "скважины" : "тёплого пола") + " пропал (" + where +
", компрессор работал " + ran + " с) — проверьте насос и фильтр");
raise(f);
}
void control() {
uint32_t now = millis();
bool want = demand();
// Защиты, не зависящие от состояния: давление и датчики давления —
// пока стоит компрессор, они показывают давление в покое.
bool running = state == S_RUN;
if (state != S_FAULT) {
if (hPSens.step(cfg.enabled && (isnan(pLow) || isnan(pHigh)), 3000)) raise(F_PSENSOR);
else if (running && hHigh.step(pHigh > cfg.maxHigh, 1000)) raise(F_HIGH);
else if (running && hDisch.step(max(isnan(temp[T_DISCH]) ? 0 : temp[T_DISCH],
isnan(temp[T_COMP]) ? 0 : temp[T_COMP]) > cfg.maxDisch, 2000)) raise(F_DISCH);
}
// Цепь аппаратной защиты (HP, LP, термостаты, реле протока). Разомкнулась,
// пока установка работала или запускалась, — компрессор уже отпущен
// контактором; контроллеру остаётся честно записать причину.
bool chainWatch = state == S_PUMPS || state == S_RUN;
if (cfg.hwUse && chainWatch && hChain.step(!chainOk, 1000)) raiseChain("в работе");
else if (!chainWatch) hChain.step(false, 0);
// Ток компрессора: после пускового броска — не ниже и не выше предела.
bool curWatch = cfg.curUse && state != S_FAULT && relayOn[0] && now - compOnAt > cfg.tCurGrace * 1000UL;
if (curWatch && hCurHigh.step(!isnan(amps) && amps > cfg.curMax, 1000)) {
note(String("ток компрессора ") + fmt(amps) + " А, предел " + fmt(cfg.curMax) + " А");
raise(F_CUR_HIGH);
} else if (curWatch && hCurLow.step(!isnan(amps) && amps < cfg.curMin, 3000)) {
note(String("ток компрессора ") + fmt(amps) + " А, ниже " + fmt(cfg.curMin) + " А при включённом реле");
raise(F_CUR_LOW);
} else if (!curWatch) {
hCurHigh.step(false, 0);
hCurLow.step(false, 0);
}
if (state != S_OFF) hChainIdle.step(false, 0);
switch (state) {
case S_OFF: {
// В покое всё выключено. Сброс аварии во время выбега насосов (или
// команда с панели) оставил бы их включёнными, поэтому гасим здесь.
setRelay(1, R_COLD, false);
setRelay(2, R_HOT, false);
// Готовы пускать (кроме цепи защит): тогда разомкнутая цепь — причина,
// почему пуска нет, и о ней надо сказать, а не молчать.
bool ready = eevHomed && want &&
!(now < cfg.tBoot * 1000UL || (stoppedOnce && now - stopAt < cfg.tMinOff * 1000UL));
if (cfg.hwUse && hChainIdle.step(ready && !chainOk, 3000)) { raiseChain("перед пуском"); break; }
if (!ready || (cfg.hwUse && !chainOk)) break;
if (!tempsCritical()) {
if (hSens.step(true, 3000)) raise(F_SENSOR);
break;
}
hSens.step(false, 0);
setRelay(1, R_COLD, true);
eev.enableOutputs();
// Перед пуском ЭРВ открывается широко (eevPre): давления по сторонам
// выравниваются, и компрессору легче стартовать. На стартовое
// положение вентиль встаёт уже после включения компрессора.
eevTarget = min(cfg.eevPre, cfg.eevMax);
eev.moveTo(lroundf(eevTarget));
enter(S_PUMPS);
note("запуск: насос скважины включён");
break;
}
case S_PUMPS: {
if (!relayOn[2] && now - stateAt >= 2000) {
setRelay(2, R_HOT, true);
note("насос тёплого пола включён");
}
uint32_t wait = cfg.tFlow * 1000UL;
if (!flowCold && now - stateAt >= wait) { flowLost(F_FLOW_COLD, "при пуске"); break; }
if (relayOn[2] && !flowHot && now - stateAt >= 2000 + wait) { flowLost(F_FLOW_HOT, "при пуске"); break; }
if (!want) { setRelay(1, R_COLD, false); setRelay(2, R_HOT, false); eevClose(); enter(S_OFF); note("запрос снят, насосы выключены"); break; }
if (flowCold && flowHot && relayOn[2] && now - stateAt >= cfg.tPumpBefore * 1000UL && eev.distanceToGo() == 0) {
setRelay(0, R_COMP, true);
float sp = startPos();
eevTarget = sp;
eev.moveTo(lroundf(sp));
enter(S_RUN);
note(String("компрессор включён, давление ") + fmt(pLow) + " / " + fmt(pHigh) + " бар, ЭРВ " +
lroundf(sp) + " шагов" + (cfg.useTable ? " (по таблице)" : " (константа)"));
}
break;
}
case S_RUN: {
bool grace = now - stateAt < cfg.tGrace * 1000UL;
if (hFlowC.step(!flowCold, 3000)) { flowLost(F_FLOW_COLD, "в работе"); break; }
if (hFlowH.step(!flowHot, 3000)) { flowLost(F_FLOW_HOT, "в работе"); break; }
if (!grace && hLow.step(pLow < cfg.minLow, 5000)) { raise(F_LOW); break; }
if (hFreeze.step(!isnan(temp[T_COLD_OUT]) && temp[T_COLD_OUT] < cfg.minColdOut, 5000)) { raise(F_FREEZE); break; }
if (hSens.step(!tempsCritical(), 10000)) { raise(F_SENSOR); break; }
if (now - stateAt > 600000UL && retryCount) retryCount = 0; // десять минут без аварий — счёт попыток заново
if (!want && now - stateAt >= cfg.tMinRun * 1000UL) {
setRelay(0, R_COMP, false);
stopAt = now;
stoppedOnce = true;
eevClose();
enter(S_COOL);
persistIfDirty();
note(String("компрессор выключен, работал ") + lastRun + " с");
}
break;
}
case S_COOL:
if (now - stateAt >= cfg.tPumpAfter * 1000UL) {
setRelay(1, R_COLD, false);
setRelay(2, R_HOT, false);
enter(S_OFF);
note("насосы выключены");
}
break;
case S_FAULT:
// Насосы после аварии по давлению и температуре ещё крутятся —
// унести тепло; при пропавшем протоке — сразу стоп, они и так сухие.
if (now - faultAt >= cfg.tPumpAfter * 1000UL || fault == F_FLOW_COLD || fault == F_FLOW_HOT) {
setRelay(1, R_COLD, false);
setRelay(2, R_HOT, false);
}
if (recoverable(fault) && retryCount < cfg.retries && now - faultAt >= cfg.tRetry * 1000UL) {
retryCount++;
fault = F_NONE;
enter(S_OFF);
note(String("повторный пуск, попытка ") + retryCount);
}
break;
}
setRelay(3, R_ALARM, state == S_FAULT);
}
// --- кнопка -----------------------------------------------------------
void handleButton() {
static bool down = false, longDone = false;
static uint32_t at = 0;
bool now = digitalRead(PIN_BUTTON) == LOW;
if (now && !down) { down = true; longDone = false; at = millis(); }
if (down && now && !longDone && millis() - at >= 1500) {
longDone = true;
clearFault("кнопка");
}
if (!now && down) {
down = false;
if (!longDone && millis() - at >= 40) {
cfg.enabled = !cfg.enabled;
saveSettings();
note(cfg.enabled ? "включено кнопкой" : "выключено кнопкой");
}
}
}
// --- MQTT -------------------------------------------------------------
// Каждое значение — в своей теме <MQTT_BASE>/<имя>, с флагом retain, и
// только когда оно изменилось (но не чаще раза в MQ_GAP мс) либо раз в
// MQ_REFRESH мс. Так Home Assistant или другой клиент получает готовые
// датчики без разбора JSON. Контроллер только публикует: команд из сети он
// не принимает — управление остаётся на странице и кнопке.
enum {
M_STATE, M_FAULT, M_FAULT_TEXT, M_WARNING, M_COMP, M_PUMP_COLD, M_PUMP_HOT, M_ALARM, M_SUPERHEAT, M_SUBCOOL,
M_TEVAP, M_TCOND, M_PLOW, M_PHIGH, M_EEV, M_STARTS_H, M_STARTS_T, M_CURRENT, M_CHAIN,
M_FLOW_COLD, M_FLOW_HOT, M_T0,
M_ITEMS = M_T0 + SENSORS
};
const char *MQ_NAME[M_T0] = {
"state", "fault", "fault_text", "warning", "compressor", "pump_well", "pump_floor", "alarm", "superheat", "subcool",
"t_evap", "t_cond", "p_low", "p_high", "eev_steps", "starts_hour", "starts_total", "current", "protection_chain",
"flow_well", "flow_floor",
};
WiFiClient net;
PubSubClient mqtt(net);
String mqLast[M_ITEMS];
uint32_t mqAt[M_ITEMS];
uint32_t mqTry = 0;
const uint32_t MQ_GAP = SIM_DEMO ? 1000 : 5000;
const uint32_t MQ_REFRESH = SIM_DEMO ? 20000 : 300000;
String onOff(bool v) { return v ? String("on") : String("off"); }
String numText(float v, byte dec = 1) { return isnan(v) ? String("unavailable") : String(v, (unsigned int)dec); }
String mqValue(byte i) {
switch (i) {
case M_STATE: return String(STATE_ID[state]);
case M_FAULT: return String(FAULT_ID[fault]);
case M_FAULT_TEXT: return String(FAULT_NAME[fault]);
case M_WARNING: return String(lowCharge ? "low_charge" : "none");
case M_COMP: return onOff(relayOn[0]);
case M_PUMP_COLD: return onOff(relayOn[1]);
case M_PUMP_HOT: return onOff(relayOn[2]);
case M_ALARM: return onOff(relayOn[3]);
case M_SUPERHEAT: return numText(superheat);
case M_SUBCOOL: return numText(subcool);
case M_TEVAP: return numText(tEvap);
case M_TCOND: return numText(tCond);
case M_PLOW: return numText(pLow);
case M_PHIGH: return numText(pHigh);
case M_EEV: return String((long)eev.currentPosition());
case M_STARTS_H: return String((unsigned int)startsHour());
case M_STARTS_T: return String((unsigned long)startsTotal);
case M_CURRENT: return numText(amps);
case M_CHAIN: return String(chainOk ? "closed" : "open");
case M_FLOW_COLD: return String(flowCold ? "yes" : "no");
case M_FLOW_HOT: return String(flowHot ? "yes" : "no");
default: return numText(temp[i - M_T0]);
}
}
String mqTopic(byte i) {
String t = String(MQTT_BASE) + "/";
if (i < M_T0) return t + MQ_NAME[i];
return t + "temp/" + SENSOR[i - M_T0].key;
}
// К брокеру подключаемся не чаще раза в 30 секунд (пока компрессор идёт —
// раз в две минуты): connect() без ответа брокера держит loop() до трёх
// секунд, а на это время встают ЭРВ и защиты.
void mqttLoop() {
if (!cfg.mqttOn || WiFi.status() != WL_CONNECTED) {
if (mqtt.connected()) mqtt.disconnect();
return;
}
if (!mqtt.connected()) {
uint32_t gap = relayOn[0] ? 120000UL : 30000UL;
if (mqTry && millis() - mqTry < gap) return;
mqTry = millis();
String id = "heatpump-water";
String will = String(MQTT_BASE) + "/status";
bool ok = *MQTT_USER
? mqtt.connect(id.c_str(), MQTT_USER, MQTT_PASSWORD, will.c_str(), 0, true, "offline")
: mqtt.connect(id.c_str(), will.c_str(), 0, true, "offline");
if (!ok) {
Serial.printf("MQTT: брокер не ответил (код %d)\n", mqtt.state());
return;
}
mqtt.publish(will.c_str(), "online", true);
for (byte i = 0; i < M_ITEMS; i++) mqLast[i] = ""; // после подключения — всё заново
Serial.printf("MQTT: подключено к %s\n", BROKER);
}
mqtt.loop();
uint32_t now = millis();
for (byte i = 0; i < M_ITEMS; i++) {
bool never = mqLast[i].length() == 0;
if (!never && now - mqAt[i] < MQ_GAP) continue;
String v = mqValue(i);
if (!never && v == mqLast[i] && now - mqAt[i] < MQ_REFRESH) continue;
mqtt.publish(mqTopic(i).c_str(), v.c_str(), true);
mqLast[i] = v;
mqAt[i] = now;
}
}
// --- страница: GyverLibs/Settings ---------------------------------------
SettingsESP sett("Тепловой насос");
enum : size_t {
W_STATE = 1, W_FAULT, W_WARN, W_LED_COMP, W_LED_COLD, W_LED_HOT, W_LED_ALARM, W_JOURNAL,
W_PLOW, W_PHIGH, W_TEVAP, W_TCOND, W_SH, W_SC, W_EEV, W_FLOWC, W_FLOWH, W_TARGET,
W_CHAIN, W_CUR, W_STARTS_H, W_STARTS_T, W_CYCLE, W_PIF, W_STARTPOS, W_LEARNED, W_MQTT,
W_FOUND = 40, W_DUP = 45, W_T = 50,
};
String stateText() {
uint32_t s = (millis() - stateAt) / 1000;
String t = STATE_NAME[state];
if (state == S_FAULT) t += String(": ") + FAULT_NAME[fault];
else if (state == S_OFF && cfg.enabled) {
long left = (long)cfg.tBoot - (long)(millis() / 1000);
long left2 = stoppedOnce ? (long)cfg.tMinOff - (long)((millis() - stopAt) / 1000) : 0;
left = max(left, left2);
if (left > 0) t += String(", пуск через ") + left + " с";
else if (!eevHomed) t += ", ЭРВ ищет ноль";
else if (!heat) t += ", тепла не нужно";
} else if (state != S_OFF) t += String(", ") + s + " с";
return t;
}
// Оценка буфера по циклу: компрессор, который работает меньше пары минут и
// включается чаще предела, не успевает прогреть воду — контуру мало воды.
String cycleText() {
if (!startsTotal) return "ещё не было";
return String("работа ") + lastRun + " с, пауза перед ним " + lastOff + " с";
}
String chainText() {
if (!cfg.hwUse) return "не контролируется";
return chainOk ? "замкнута" : "РАЗОМКНУТА";
}
String tempText(byte i) {
if (!haveAddr[i]) return "не назначен";
return isnan(temp[i]) ? "нет ответа" : String(temp[i], 1) + " °C";
}
// --- выбор датчиков в панели ---------------------------------------------
// Что выбрано в списке роли: 0 — не назначен, 1…n — найденный датчик её
// линии, n+1 — адрес назначен, но на линии его сейчас нет.
uint8_t pick[SENSORS];
uint8_t pickOf(byte role) {
byte l = SENSOR[role].line;
if (!haveAddr[role]) return 0;
for (byte i = 0; i < foundCount[l]; i++)
if (!memcmp(found[l][i], addr[role], 8)) return i + 1;
return foundCount[l] + 1;
}
// Список для выпадающего меню: последние цифры адреса и температура —
// тёплый датчик легко узнать, подержав его в руке.
String pickList(byte role) {
byte l = SENSOR[role].line;
String s = "не назначен";
for (byte i = 0; i < foundCount[l]; i++) {
s += String(";") + (i + 1) + ") …" + addrText(found[l][i]).substring(10) + ", " +
(isnan(foundTemp[l][i]) ? String("—") : String(foundTemp[l][i], 1)) + " °C";
}
if (haveAddr[role] && pickOf(role) > foundCount[l]) s += String(";нет на линии: …") + String(cfg.addr[role]).substring(10);
return s;
}
// Один адрес у двух ролей — почти всегда ошибка.
String dupText() {
String s;
for (byte i = 0; i < SENSORS; i++)
for (byte j = i + 1; j < SENSORS; j++)
if (haveAddr[i] && haveAddr[j] && !memcmp(addr[i], addr[j], 8)) {
s += String(s.length() ? "; " : "") + SENSOR[i].name + " = " + SENSOR[j].name;
}
return s.length() ? s : String("нет");
}
// Раздать найденные датчики ролям каждой линии по порядку адресов.
// Порядок у 1-Wire — по возрастанию адреса, а не по расположению на трубе,
// поэтому это стартовая точка: дальше роль уточняют в списке.
void assignInOrder() {
for (byte l = 0; l < LINES; l++) {
byte n = 0;
for (byte r = 0; r < SENSORS; r++) {
if (SENSOR[r].line != l) continue;
if (n < foundCount[l]) strncpy(cfg.addr[r], addrText(found[l][n]).c_str(), 16);
else cfg.addr[r][0] = 0;
cfg.addr[r][16] = 0;
n++;
}
}
bindAddresses();
}
String foundText(byte l) {
if (!foundCount[l]) return "датчиков не найдено — проверьте провод и резистор 4,7 кОм";
String s;
for (byte i = 0; i < foundCount[l]; i++) {
s += addrText(found[l][i]) + " " + (isnan(foundTemp[l][i]) ? String("—") : String(foundTemp[l][i], 1)) + " °C\n";
}
return s;
}
String eevText() {
int pos = eev.currentPosition();
return String(pos) + " шагов, " + (pos * 100L / max((int)cfg.eevMax, 1)) + " %";
}
String startPosText() {
return String(lroundf(startPos())) + " шагов" + (cfg.useTable ? "" : " (таблица выключена)");
}
void build(sets::Builder &b) {
bool changed = false;
if (b.beginGroup("Сейчас")) {
b.Label(W_STATE, "Состояние", stateText());
b.Label(W_FAULT, "Авария", fault == F_NONE ? String("нет") : String(FAULT_NAME[fault]));
b.Label(W_WARN, "Предупреждение", warnText());
b.LED(W_LED_COMP, "Компрессор", relayOn[0], sets::Colors::Gray, sets::Colors::Green);
b.LED(W_LED_COLD, "Насос скважины", relayOn[1], sets::Colors::Gray, sets::Colors::Green);
b.LED(W_LED_HOT, "Насос тёплого пола", relayOn[2], sets::Colors::Gray, sets::Colors::Green);
b.LED(W_LED_ALARM, "Реле аварии", relayOn[3], sets::Colors::Gray, sets::Colors::Red);
b.Label(W_FLOWC, "Проток скважины", flowCold ? "есть" : "нет");
b.Label(W_FLOWH, "Проток тёплого пола", flowHot ? "есть" : "нет");
b.Label(W_CHAIN, "Цепь аппаратной защиты", chainText());
b.Label(W_CUR, "Ток компрессора", fmt(amps) + " А");
b.Label(W_STARTS_H, "Пусков за последний час", String((unsigned int)startsHour()));
b.Label(W_STARTS_T, "Пусков всего", String((unsigned long)startsTotal));
b.Label(W_CYCLE, "Последний цикл", cycleText());
b.endGroup();
}
if (b.beginGroup("Управление")) {
if (b.Switch("Установка включена", &cfg.enabled)) {
changed = true;
note(cfg.enabled ? "включено со страницы" : "выключено со страницы");
}
if (b.Select("Как решаем, греть ли", "Всегда (ручной);По помещению;По подаче пола;По обратке пола", &cfg.mode)) changed = true;
if (b.Number("Целевая температура, °C", &cfg.target, 5, 60)) changed = true;
if (b.Number("Прибавка на градус мороза, К (0 — выкл.)", &cfg.wcSlope, 0, 2)) changed = true;
if (b.Switch("Кривая по точкам вместо наклона", &cfg.curveOn)) changed = true;
b.Label(W_TARGET, "Цель подачи сейчас", fmt(cfg.mode == 1 ? cfg.target : targetNow()) + " °C");
if (b.Number("Гистерезис, °C", &cfg.hyst, 0.5, 10)) changed = true;
#if !REAL_BUILD
if (b.Select("Фреон", FREON_NAME, &cfg.freon)) {
changed = true;
note("фреон сменён — таблицу стартовой позиции можно пересчитать кнопкой «Заполнить по формуле»");
}
#endif
if (b.beginButtons()) {
if (b.Button("Сбросить аварию", sets::Colors::Aqua)) clearFault("страница");
if (b.Button("Аварийный стоп", sets::Colors::Red)) emergencyStop();
b.endButtons();
}
b.endGroup();
}
if (b.beginGroup("Отопительная кривая")) {
for (byte i = 0; i < CV_N; i++) {
if (b.Number(String("Кривая, точка ") + (i + 1) + ": улица, °C", &cfg.curveT[i], -40, 30)) changed = true;
if (b.Number(String("Кривая, точка ") + (i + 1) + ": подача, °C", &cfg.curveV[i], 5, 60)) changed = true;
}
b.endGroup();
}
if (b.beginGroup("Температуры")) {
for (byte i = 0; i < SENSORS; i++) b.Label(W_T + i, SENSOR[i].name, tempText(i));
b.endGroup();
}
if (b.beginGroup("Фреон и ЭРВ")) {
b.Label(W_PLOW, "Давление низкое", fmt(pLow) + " бар");
b.Label(W_PHIGH, "Давление высокое", fmt(pHigh) + " бар");
b.Label(W_TEVAP, "Кипение (по давлению)", fmt(tEvap) + " °C");
b.Label(W_TCOND, "Конденсация (по давлению)", fmt(tCond) + " °C");
b.Label(W_SH, "Перегрев на всасывании", fmt(superheat) + " К");
b.Label(W_SC, "Переохлаждение жидкости", fmt(subcool) + " К");
b.Label(W_EEV, "ЭРВ", eevText());
b.Label(W_PIF, "Масштаб шага ПИ сейчас", String(piFactor(), 2));
b.endGroup();
}
if (b.beginGroup("Стартовая позиция ЭРВ")) {
if (b.Switch("Брать из таблицы (иначе константа «После пуска»)", &cfg.useTable)) changed = true;
b.Label(W_STARTPOS, "Для текущих условий", startPosText());
for (byte i = 0; i < SP_N; i++) {
if (b.Number(String("ЭРВ, точка ") + (i + 1) + ": вода из скважины, °C", &cfg.spKey[i], -5, 40)) changed = true;
if (b.Number(String("ЭРВ, точка ") + (i + 1) + ": шагов", &cfg.spPos[i], 0, 2000)) changed = true;
}
if (b.Number("Поправка: шагов на градус обратки", &cfg.spRetK, -20, 20)) changed = true;
if (b.Number("Обратка, от которой считается поправка, °C", &cfg.spRetRef, 10, 50)) changed = true;
if (b.Switch("Самообучение таблицы", &cfg.learn)) changed = true;
if (b.Number("Перегрев у цели не меньше, с", &cfg.learnTime, 10, 3600)) changed = true;
b.Label(W_LEARNED, "Подстроено раз", String((unsigned int)learned));
if (b.Button("Заполнить по формуле", sets::Colors::Yellow)) {
fillTable();
changed = true;
note("таблица стартовой позиции заполнена по формуле");
}
b.endGroup();
}
if (b.beginGroup("Защиты")) {
if (b.Number("Низкое давление, бар", &cfg.minLow, 0, 10)) changed = true;
if (b.Number("Высокое давление, бар", &cfg.maxHigh, 5, 60)) changed = true;
if (b.Number("Нагнетание не выше, °C", &cfg.maxDisch, 60, 140)) changed = true;
if (b.Number("Выход испарителя не ниже, °C", &cfg.minColdOut, -5, 10)) changed = true;
if (b.Spinner("Попыток после аварии", 0, 9, 1, &cfg.retries)) changed = true;
if (b.Switch("Контролировать цепь аппаратной защиты", &cfg.hwUse)) changed = true;
if (b.Spinner("Предупреждать, если пусков за час больше", 0, 20, 1, &cfg.maxStarts)) changed = true;
b.endGroup();
}
if (b.beginGroup("Ток компрессора")) {
if (b.Switch("Защита по току", &cfg.curUse)) changed = true;
if (b.Number("Шкала датчика тока, А", &cfg.curRange, 1, 100)) changed = true;
if (b.Number("Ток не ниже, А", &cfg.curMin, 0, 50)) changed = true;
if (b.Number("Ток не выше, А", &cfg.curMax, 1, 100)) changed = true;
if (b.Number("Не проверять после пуска, с", &cfg.tCurGrace, 0, 60)) changed = true;
b.endGroup();
}
if (b.beginGroup("Задержки, с")) {
if (b.Number("После включения питания", &cfg.tBoot, 0, 900)) changed = true;
if (b.Number("Насосы до компрессора", &cfg.tPumpBefore, 0, 300)) changed = true;
if (b.Number("Насосы после компрессора", &cfg.tPumpAfter, 0, 300)) changed = true;
if (b.Number("Пауза между пусками", &cfg.tMinOff, 0, 900)) changed = true;
if (b.Number("Минимум работы", &cfg.tMinRun, 0, 900)) changed = true;
if (b.Number("Ждать проток", &cfg.tFlow, 1, 120)) changed = true;
if (b.Number("До повтора после аварии", &cfg.tRetry, 5, 3600)) changed = true;
if (b.Number("Не проверять низкое давление после пуска", &cfg.tGrace, 0, 300)) changed = true;
if (b.Number("ЭРВ не трогать после пуска", &cfg.tEev, 0, 300)) changed = true;
if (b.Number("Нехватка фреона: ЭРВ на максимуме дольше, с (0 — не следить)", &cfg.tLowCharge, 0, 3600)) changed = true;
if (b.Number("Нехватка фреона: перегрев выше цели на, К", &cfg.lowChargeDelta, 1, 20)) changed = true;
b.endGroup();
}
if (b.beginGroup("ЭРВ")) {
if (b.Number("Перегрев, К", &cfg.shTarget, 1, 15)) changed = true;
if (b.Number("Kp, шагов на градус", &cfg.kp, 0, 50)) changed = true;
if (b.Number("Ki, шагов на градус в секунду", &cfg.ki, 0, 10)) changed = true;
if (b.Switch("Масштабировать Kp и Ki по открытию", &cfg.piScale)) changed = true;
if (b.Number("Открытие, при котором Kp и Ki как заданы, шагов", &cfg.piRef, 10, 2000)) changed = true;
if (b.Number("Степень масштаба (1 — пропорция)", &cfg.piPow, 0, 3)) changed = true;
if (b.Number("Минимум, шагов", &cfg.eevMin, 0, 1000)) changed = true;
if (b.Number("Максимум, шагов", &cfg.eevMax, 50, 2000)) changed = true;
if (b.Number("Перед пуском (выравнивание), шагов", &cfg.eevPre, 0, 2000)) changed = true;
if (b.Number("После пуска без таблицы, шагов", &cfg.eevStart, 0, 2000)) changed = true;
if (b.Button("Найти ноль заново", sets::Colors::Aqua)) eevHome();
b.endGroup();
}
if (b.beginGroup("Датчики давления")) {
if (b.Number("Шкала низкого, бар", &cfg.loRange, 1, 100)) changed = true;
if (b.Number("Шкала высокого, бар", &cfg.hiRange, 1, 100)) changed = true;
if (b.Number("Делитель перед АЦП (Uдатчика / Uвхода)", &cfg.divider, 1, 3)) changed = true;
b.endGroup();
}
if (b.beginGroup("MQTT")) {
if (b.Switch("Публиковать в MQTT", &cfg.mqttOn)) changed = true;
b.Label(W_MQTT, "Связь с брокером", mqtt.connected() ? String("на связи") : String("нет"));
b.endGroup();
}
if (b.beginGroup("Датчики температуры")) {
for (byte l = 0; l < LINES; l++)
b.Label(W_FOUND + l, String("Линия ") + (l + 1) + " (D" + PIN_OW[l] + ")",
String("найдено ") + foundCount[l]);
for (byte i = 0; i < SENSORS; i++) {
pick[i] = pickOf(i);
if (b.Select(SENSOR[i].name, pickList(i), &pick[i])) {
byte l = SENSOR[i].line;
if (pick[i] == 0) cfg.addr[i][0] = 0;
else if (pick[i] <= foundCount[l]) {
strncpy(cfg.addr[i], addrText(found[l][pick[i] - 1]).c_str(), 16);
cfg.addr[i][16] = 0;
}
changed = true;
}
}
b.Label(W_DUP, "Один датчик у двух ролей", dupText());
if (b.beginButtons()) {
if (b.Button("Опросить линии", sets::Colors::Aqua)) { scanLines(); note("линии опрошены заново"); }
if (b.Button("Назначить по порядку", sets::Colors::Yellow)) { assignInOrder(); changed = true; }
b.endButtons();
}
b.endGroup();
}
b.Log(W_JOURNAL, journal, "Журнал");
if (changed) {
sortTables();
bindAddresses();
saveSettings();
note("настройки сохранены (страница)");
}
}
void update(sets::Updater &u) {
u.update(W_STATE, stateText());
u.update(W_FAULT, fault == F_NONE ? String("нет") : String(FAULT_NAME[fault]));
u.update(W_WARN, warnText());
u.update(W_LED_COMP, relayOn[0]);
u.update(W_LED_COLD, relayOn[1]);
u.update(W_LED_HOT, relayOn[2]);
u.update(W_LED_ALARM, relayOn[3]);
u.update(W_FLOWC, flowCold ? "есть" : "нет");
u.update(W_FLOWH, flowHot ? "есть" : "нет");
u.update(W_CHAIN, chainText());
u.update(W_CUR, fmt(amps) + " А");
u.update(W_STARTS_H, String((unsigned int)startsHour()));
u.update(W_STARTS_T, String((unsigned long)startsTotal));
u.update(W_CYCLE, cycleText());
u.update(W_TARGET, fmt(cfg.mode == 1 ? cfg.target : targetNow()) + " °C");
u.update(W_PIF, String(piFactor(), 2));
u.update(W_STARTPOS, startPosText());
u.update(W_LEARNED, String((unsigned int)learned));
u.update(W_MQTT, mqtt.connected() ? String("на связи") : String("нет"));
for (byte i = 0; i < SENSORS; i++) u.update(W_T + i, tempText(i));
u.update(W_PLOW, fmt(pLow) + " бар");
u.update(W_PHIGH, fmt(pHigh) + " бар");
u.update(W_TEVAP, fmt(tEvap) + " °C");
u.update(W_TCOND, fmt(tCond) + " °C");
u.update(W_SH, fmt(superheat) + " К");
u.update(W_SC, fmt(subcool) + " К");
u.update(W_EEV, eevText());
for (byte l = 0; l < LINES; l++) u.update(W_FOUND + l, String("найдено ") + foundCount[l]);
u.update(W_DUP, dupText());
u.update(W_JOURNAL, journal);
}
String ipText = "нет сети";
void startNetwork() {
WiFi.mode(WIFI_STA);
WiFi.begin(SSID, PASSWORD);
unsigned long t0 = millis();
while (WiFi.status() != WL_CONNECTED && millis() - t0 < 15000) delay(250);
if (WiFi.status() == WL_CONNECTED) {
ipText = WiFi.localIP().toString();
} else {
// Контроллер обязан работать и без сети: страница нужна для настройки,
// а не для работы. Своя точка доступа — чтобы настроить его в подвале.
WiFi.mode(WIFI_AP);
WiFi.softAP(AP_SSID, AP_PASSWORD);
ipText = WiFi.softAPIP().toString();
Serial.printf("сети %s нет — своя точка доступа %s\n", SSID, AP_SSID);
}
// На чистой флеш-памяти begin(true) размечает LittleFS; при этом она
// пишет в порт, что раздел испорчен, — при первом включении так и должно быть.
if (!LittleFS.begin(true)) Serial.println("LittleFS не поднялась");
if (*ADMIN_PASSWORD) sett.setPass(ADMIN_PASSWORD);
sett.begin();
sett.onBuild(build);
sett.onUpdate(update);
sett.setUpdatePeriod(2000);
mqtt.setServer(BROKER, BROKER_PORT);
mqtt.setBufferSize(256);
#if USE_OTA
// Прошивка по воздуху. Во время обновления установка выключается: плата
// на несколько секунд перестаёт управлять реле.
ArduinoOTA.setHostname("heatpump-water");
ArduinoOTA.onStart([]() { emergencyStop(); });
ArduinoOTA.begin();
#endif
Serial.printf("тепловой насос: http://%s/\n", ipText.c_str());
}
// --- монитор порта ----------------------------------------------------
void handleSerial() {
static String buf;
while (Serial.available()) {
char c = Serial.read();
if (c != '\n' && c != '\r') { if (buf.length() < 32) buf += c; continue; }
if (!buf.length()) continue;
if (buf == "status") {
Serial.println(stateText());
for (byte i = 0; i < SENSORS; i++) Serial.printf(" %s: %s\n", SENSOR[i].name, tempText(i).c_str());
Serial.printf(" давление %s / %s бар, кипение %s, конденсация %s, перегрев %s, переохлаждение %s, ЭРВ %s\n",
fmt(pLow).c_str(), fmt(pHigh).c_str(), fmt(tEvap).c_str(), fmt(tCond).c_str(),
fmt(superheat).c_str(), fmt(subcool).c_str(), eevText().c_str());
Serial.printf(" ток %s А, цепь защит %s, пусков за час %u, всего %lu\n",
fmt(amps).c_str(), chainText().c_str(), (unsigned int)startsHour(), (unsigned long)startsTotal);
} else if (buf == "table") {
for (byte i = 0; i < SP_N; i++)
Serial.printf(" скважина %s °C -> %u шагов\n", fmt(cfg.spKey[i]).c_str(), (unsigned int)cfg.spPos[i]);
Serial.printf(" поправка %s шага на градус обратки выше %s °C\n", fmt(cfg.spRetK, 2).c_str(), fmt(cfg.spRetRef).c_str());
} else if (buf == "scan") {
scanLines();
for (byte l = 0; l < LINES; l++) Serial.print(foundText(l));
} else if (buf == "reset") {
clearFault("порт");
} else {
Serial.printf("команды: status, table, scan, reset (получено «%s», %u байт)\n", buf.c_str(), buf.length());
}
buf = "";
}
}
// --- setup и loop -----------------------------------------------------
void setup() {
Serial.begin(115200);
const byte relays[] = { R_COMP, R_COLD, R_HOT, R_ALARM };
for (byte r : relays) { pinMode(r, OUTPUT); digitalWrite(r, RELAY_OFF); }
pinMode(PIN_LED, OUTPUT);
pinMode(PIN_FLOW_COLD, INPUT_PULLUP);
pinMode(PIN_FLOW_HOT, INPUT_PULLUP);
pinMode(PIN_CHAIN, INPUT_PULLUP);
pinMode(PIN_BUTTON, INPUT_PULLUP);
analogSetPinAttenuation(PIN_P_HIGH, ADC_11db);
analogSetPinAttenuation(PIN_P_LOW, ADC_11db);
analogSetPinAttenuation(PIN_CUR, ADC_11db);
loadSettings();
bindAddresses();
scanLines();
eev.setMaxSpeed(EEV_SPEED);
eev.setAcceleration(EEV_ACCEL);
eevHome();
startNetwork();
note("контроллер запущен, установка " + String(cfg.enabled ? "включена" : "выключена"));
}
void loop() {
static uint32_t blinkAt = 0;
sett.tick();
#if USE_OTA
ArduinoOTA.handle();
#endif
readInputs();
readTemps();
readPressure();
readCurrent();
handleButton();
handleSerial();
control();
eevControl();
eevRun();
mqttLoop();
if (millis() - blinkAt >= (state == S_FAULT ? 250 : 1000)) {
blinkAt = millis();
digitalWrite(PIN_LED, !digitalRead(PIN_LED));
}
delay(2);
}
Тепловой насос вода–вода на ESP32, версия 2: таблица ЭРВ по скважине, защита по току и MQTT
Вторая версия контроллера теплового насоса вода–вода по замечаниям пользователей: стартовая позиция ЭРВ из таблицы «вода из скважины → шаги» с поправкой на обратку и самообучением, ПИ с шагом по открытию вентиля, счётчик пусков, вход аппаратной защиты с причиной в журнале, защита по току компрессора, необязательная погодная поправка и MQTT по темам. Первая версия остаётся в галерее.
Что на схеме
Плата ESP32 DevKit v1, к ней подключены:
- DS18B20 (термометр 1-Wire) «Всасывание» —
tf1, температура 8 °C - DS18B20 (термометр 1-Wire) «Нагнетание» —
tf2, температура 80 °C - DS18B20 (термометр 1-Wire) «Жидкость» —
tf3, температура 30 °C - DS18B20 (термометр 1-Wire) «После ЭРВ» —
tf4, температура 3 °C - DS18B20 (термометр 1-Wire) «Скв. вход» —
tc1, температура 12 °C - DS18B20 (термометр 1-Wire) «Скв. выход» —
tc2, температура 7 °C - DS18B20 (термометр 1-Wire) «Улица» —
tc3, температура 9 °C - DS18B20 (термометр 1-Wire) «Пол обратка» —
th1, температура 30 °C - DS18B20 (термометр 1-Wire) «Пол подача» —
th2, температура 34 °C - DS18B20 (термометр 1-Wire) «Помещение» —
th3, температура 22 °C - DS18B20 (термометр 1-Wire) «Корпус» —
th4, температура 45 °C - Шаговый двигатель «ЭРВ» —
eev - Потенциометр «Давл. высокое» —
ph - Потенциометр «Давл. низкое» —
pl - Потенциометр «Ток компрессора» —
pa - Переключатель «Проток скважины» —
fc - Переключатель «Проток пола» —
fh - Переключатель «Цепь защит» —
ch - Кнопка «Пуск / сброс» —
btn - Реле «Компрессор» —
k1 - Реле «Насос скважины» —
k2 - Реле «Насос пола» —
k3 - Реле «Авария» —
k4 - Ввод 230 В «Ввод 230 В» —
in - Автоматический выключатель «Цепи упр.» —
qf1 - Автоматический выключатель «Компрессор» —
qf2 - Контактор «Контактор» —
km1 - Двигатель однофазный «Компрессор» —
mc - Двигатель однофазный «Насос скважины» —
mw - Двигатель однофазный «Насос пола» —
mf - Светильник 230 В «Лампа аварии» —
la1
Задействованы выводы платы: 3V3, GND, D4, D5, D23, D18, D19, D21, D22, D34, D35, VN, D14, D27, D16, D13, D25, D26, D32, D33. Всего соединений на схеме: 83.
Что это и чем отличается от первой версии
Второй вариант установки вода–вода на базе наружного блока кондиционера «старт–стоп»: вода из скважины кипятит фреон в испарителе, компрессор его сжимает, в конденсаторе фреон отдаёт тепло воде тёплого пола, а электронный расширительный вентиль (ЭРВ) на шаговом двигателе дросселирует жидкость обратно к испарителю. Первую версию мы оставили в галерее («Тепловой насос вода–вода на ESP32: 11 датчиков DS18B20, ЭРВ и панель Settings»); эта собрана по замечаниям тех, кто её повторял. Что изменилось:
- Стартовая позиция ЭРВ — из таблицы «температура воды из скважины на входе (tc1) → шагов», с поправкой на обратку пола (th1), а не константа 220.
- Шаг ПИ зависит от открытия вентиля (флаг в панели).
- Счётчик пусков за час и за всё время, длительность последнего цикла — оценка буфера.
- Вход «цепь аппаратной защиты»: если цепь разомкнулась, в журнале — что видели датчики в этот момент.
- Защита по току компрессора: слишком малый (обрыв, сухой ход) и слишком большой.
- MQTT отдельными темами, по значению на тему.
- Погодозависимость — по желанию, наклоном или кривой по четырём точкам; по умолчанию выключена.
- Один фреон для сборки на железе и обновление по воздуху — константами в начале скетча.
- Исправлена ошибка первой версии: если сбросить аварию, пока насосы ещё в выбеге, они оставались включёнными до следующего пуска. Теперь в покое насосы выключаются всегда.
Одиннадцать датчиков на трёх линиях 1-Wire, давления, перегрев и переохлаждение, защиты, панель Settings остались как в первой версии — подробности в её статье.
Стартовая позиция ЭРВ: ключ — вода из скважины
У воды из скважины температура почти постоянна, но в разных местах и в разные сезоны она разная — от 4 до 14 °C. От неё зависит давление кипения, а значит, и то, сколько фреона вентиль должен пропустить в первые минуты. Поэтому ключ таблицы — температура воды на входе испарителя (датчик tc1), а не уличная, как в варианте воздух–вода. Таблица из пяти точек (2, 5, 8, 11, 14 °C), между точками — линейная интерполяция, за краями — значение крайней точки. Сверху добавляется поправка на обратку пола: чем она горячее, тем выше давление конденсации и тем больше нужно прикрыть вентиль (шагов на градус выше опорной обратки 30 °C).
Откуда берутся числа по умолчанию. Из формулы расхода: вентиль пропускает тем больше, чем больше перепад давления, а нужен ему расход компрессора, который растёт с давлением кипения:
шагов = eevStart · (Pкип / Pкип0) · √((Pконд0 − Pкип0) / (Pконд − Pкип))
Опора — кипение 0 °C, конденсация 40 °C, 220 шагов. Кипение принимается на 6 К ниже воды из скважины, конденсация — на 8 К выше обратки. Это первое приближение, а не паспорт вентиля: настоящая характеристика ЭРВ нелинейна, поэтому таблицу подстраивают руками (панель → «Стартовая позиция ЭРВ») или оставляют самообучению. При смене фреона кнопка «Заполнить по формуле» пересчитывает таблицу для нового.
Самообучение. Если перегрев держался у цели (в пределах ±1 К) дольше заданного времени (30 с в симуляторе, 10 минут на железе), текущее открытие считается подходящим для этой температуры скважины, и два соседних узла таблицы сдвигаются к нему на 20 % расхождения — пропорционально весам. Запись во флеш происходит только при остановке компрессора, а не по ходу работы: NVS рассчитана на ограниченное число записей. В журнале строка «самообучение: …» показывает, что и куда сдвинулось. Выключается переключателем.
ПИ с шагом, зависящим от открытия
Скоростной ПИ-регулятор считает приращение Kp·(ошибка − прошлая) + Ki·ошибка·5 с. При малом открытии вентиля шаг в несколько шагов двигателя меняет расход сильнее, чем при большом, и один и тот же коэффициент то раскачивает контур, то вяло его ведёт. Теперь Kp и Ki умножаются на (открытие / piRef)^piPow: piRef — 250 шагов, степень 1 по умолчанию, множитель ограничен 0,25–3. В панели («ЭРВ») это переключатель «Масштабировать Kp и Ki по открытию»; выключите его — и увидите поведение первой версии.
Пуски, цикличность и буфер
Компрессор «старт–стоп» не любит частых пусков. Плата считает пуски за последний час (по меткам времени последних 24), всего (в NVS, пишется при остановке) и запоминает длительность последней работы и паузы перед ней. Если пусков за час больше предела (по умолчанию 6), в журнал идёт предупреждение: объёма воды в контуре не хватает, и установка тактует. Это и есть оценка буфера: короткая работа и частые пуски — буфер мал. Лекарство — буферная ёмкость на подаче или шире гистерезис при большей паузе между пусками; цифры в панели («Последний цикл») показывают, помогло ли. Если все зоны пола закрываются сервоприводами, оставьте байпас между подачей и обраткой — иначе насос будет гнать воду в тупик, а проток пропадёт.
Аппаратная защита и вход «цепь цела»
Предупреждение о нехватке фреона. Если ЭРВ открыт на максимум, а перегрев всё равно больше цели на 4 К и дольше 5 минут (в симуляторе — 20 секунд), плата не останавливает установку, но пишет в журнал «возможна нехватка фреона или засор фильтра», показывает «Предупреждение» на панели и публикует тему warning в MQTT. Без него такое состояние выглядит штатным: регулятор просто упёрся в предел. Порог и время — в панели (0 секунд — не следить); сообщение снимается, когда перегрев вернулся к цели. Что именно — фреон или фильтр, скетч не знает: смотрите на переохлаждение — оно есть в сообщении: около нуля значит нехватку фреона, нормальное — подозрение на фильтр.
Контроллер — не единственная защита. Для настоящего компрессора часть защит ставят аппаратно, в цепь катушки контактора — чтобы они работали, даже если плата зависла или пропало питание датчика:
- реле высокого давления (HP) — нормально замкнутый контакт последовательно в цепь катушки, без обходов. Разомкнулось — контактор отпал; многие реле HP с ручным сбросом, и пуск невозможен, пока кто-то не придёт и не разберётся;
- реле низкого давления (LP) — тоже в цепь катушки, но с обходом на время пуска. В момент пуска давление всасывания ещё не поднялось, и «голое» LP в цепи либо не даст компрессору стартовать, либо выбьет его тут же. Обход делают реле времени на 30–120 с или контактом пускового реле: он шунтирует LP только на время пуска, потом LP работает как обычно;
- термостаты (нагнетание, корпус, выход воды из испарителя против замерзания) и реле протока обоих контуров — последовательно в ту же цепь.
Вход D16 этим не управляет. Это оптрон (PC817 или модуль на 230 В), вход которого стоит после последнего контакта цепи, перед катушкой: он сообщает контроллеру «напряжение на катушке есть». На схеме его заменяет переключатель ch: замкнут на землю (движок справа) — цепь цела. Если цепь разомкнулась при работе, плата останавливает всё, поднимает аварию «сработала аппаратная защита» и пишет в журнал, что видели датчики в этот момент — высокое или низкое давление, нагнетание, потерю протока скважины или пола, близкий к замерзанию выход испарителя. Если ничего из этого не видно, так и написано: смотрите реле HP и LP, термостаты, контактор. Если цепь разомкнута до пуска, установка не стартует и через 3 с объясняет, почему. Авария по цепи сама не повторяется: кто-то должен прийти к реле.
Защита по току
Ток компрессора — потенциометр pa на входе D39 (VN), шкала 0…10 А. На железе вместо него — трансформатор тока с выпрямителем и делителем или счётчик PZEM-004T по UART: для PZEM достаточно заменить тело функции readCurrent(), остальная часть скетча берёт ток только из переменной amps. PZEM в симуляторе не эмулируется, и с настоящим датчиком эта часть не проверялась.
Через несколько секунд после пуска (пусковой бросок не считается) ток должен быть между «не ниже» (1,5 А: обрыв, сухой ход, не включился контактор) и «не выше» (8,5 А). Выход за предел — авария, которая сама не повторяется. Все три числа — в панели.
Погодозависимость: по желанию
У скважины тепло не зависит от погоды, а вот нужная температура подачи — зависит: чем холоднее на улице, тем горячее нужна вода в полу. Поэтому цель с поправкой можно включить, но по умолчанию она выключена. В панели («Управление») — «Прибавка на градус мороза» (цель задана для 0 °C на улице; 0 — выключено) или переключатель «Кривая по точкам вместо наклона» с четырьмя точками «улица → подача». Поправка применяется только в режимах «по подаче» и «по обратке»: в режиме «по помещению» цель — температура комнаты, и погода её не меняет.
Протоки, промерзание, реле аварии
Установка не запустится, пока нет протока и в скважине, и в полу; пропал проток при пуске или работе — авария (повтор через tRetry, до трёх попыток), в журнале — какой контур и сколько компрессор успел проработать. Вода на выходе испарителя холоднее порога (2 °C) дольше 5 с — авария «промерзание испарителя»: лёд в испарителе рвёт пластины. Реле 4 включено, пока идёт авария: к нему подключают лампу, сирену или вход котла.
MQTT: по значению на тему
Каждое значение публикуется в свою тему pinport/heatpump/water/<имя> с флагом retain: state, fault, fault_text, compressor, pump_well, pump_floor, alarm, superheat, subcool, t_evap, t_cond, p_low, p_high, eev_steps, starts_hour, starts_total, current, protection_chain, flow_well, flow_floor, а температуры — в temp/suction, temp/discharge, temp/liquid, temp/evap_in, temp/well_in, temp/well_out, temp/outdoor, temp/return, temp/supply, temp/room, temp/compressor. Так Home Assistant получает готовые датчики без разбора JSON. Тема меняется не чаще раза в секунду (5 секунд на железе) и раз в 20 секунд (5 минут) обновляется, даже если не менялась; статус …/status — online или offline (последняя воля). Контроллер только публикует и команд из сети не принимает. Брокер недоступен — плата работает без него и пробует подключиться не чаще раза в 30 секунд (пока компрессор идёт — в две минуты), чтобы блокировка подключения не останавливала ЭРВ и защиты.
В симуляторе любой адрес ведёт на встроенный брокер PinPort (вкладка «MQTT»); на железе пропишите свой брокер, логин и пароль в начале скетча.
Сборка на железо: константы в начале
SIM_DEMO— 0: вернутся реальные задержки (пауза 5 минут, выбег минута).REAL_BUILD— 1: в прошивке остаётся один фреон, а лишние таблицы и выбор в панели убираются.REFRIGERANT_DEFAULT— номер фреона (0 R22, 1 R410A, 2 R134a, 3 R407C, 4 R404A, 5 R290, 6 R600a).USE_OTA— 1: обновление по воздуху через ArduinoOTA; на время обновления установка останавливается. В симуляторе не проверяется.- Датчики давления через делитель, драйвер ЭРВ (ULN2003/ULN2803 или L298N/DRV8833), реле — модуль AC-DC-ESP32-Relay-x4 с выводами 25, 26, 32, 33.
Что проверено и что нет
Логика скетча — таблица и интерполяция, самообучение, запись во флеш только при остановке, масштаб ПИ, счётчик пусков, цепь защит, ток, протоки, промерзание, погодная поправка и кривая, темы MQTT, поведение без брокера, переход millis() через 2³² — прогнана на хосте с подставными библиотеками и виртуальным временем; сценарий этой страницы — тоже. Прошивка под ESP32 и сам PinPort в этих прогонах не участвовали: если что-то на вашей схеме ведёт себя иначе, пришлите журнал из порта. На железе не проверялись ни OTA, ни PZEM.
Что не так в симуляторе
Пока галочка «запускать со сценарием» снята, датчики и ток вам не принадлежат: ими управляет модель фреонового контура (вкладка «Контур»). Реле платы включают компрессор и оба насоса, шаги ЭРВ открывают клапан, а давления, температуры воды и ток компрессора возвращаются в датчики. Поэтому ПИ в симуляторе сходится: перегрев откликается на положение вентиля, а не задан рукой. Реле протоков следуют за насосами; щёлкните выключатель протока — и «оборвёте» проток, как в жизни.
Рычагов на вкладке три: температура воды из скважины, заправка и засор фильтра. Утечка заставит ЭРВ уйти на максимум и даст предупреждение о фреоне, холодная скважина (в проверке — 3 °C) — защиту от промерзания. Ручки потенциометров давления и тока и температуры датчиков при включённой модели ничего не решают: их перезаписывает модель. Со сценарием она молчит: сценарий задаёт те же датчики по своим строкам.
Модель учебная: сосредоточенные параметры, температурный глайд смесей не считается, размер установки подогнан под компрессор 1,1 кВт из этого примера. Насосы и аккумулятор на схеме нарисованы, но их динамика не считается.
Библиотеки
Пример собирается с библиотеками:
- WiFi
- Preferences
- LittleFS
- OneWire
- DallasTemperature
- AccelStepper
- PubSubClient
- Settings
Они уже установлены на сервере сборки — отмечать их в проекте вручную не нужно, список приезжает вместе с примером.
Как это устроено в коде
Основное, чем пользуется скетч:
Preferences— хранение настроек во flash, переживает перезапускStepper— шаги двигателя по фазамconstrain()— зажимает значение в границахmillis()— миллисекунды с запуска: на них строят работу без остановкиSerial.println()— печатает строку в монитор портаdigitalWrite()— выставляет на выводе высокий или низкий уровеньSerial.printf()— печатает в монитор порта с форматированиемanalogRead()— измеряет напряжение на выводе через АЦПdigitalRead()— читает уровень на выводеWiFiClient— TCP-соединение с серверомWiFi.begin()— подключается к точке доступа виртуального Wi-Fidelay()— пауза, на время которой ядро занято ожиданием
В скетче 1617 строк; свои функции: satTemp(), satPress(), formulaPos(), fillTable(), setDefaults(), sortTables(), saveSettings(), loadSettings(); подключено заголовков: 9. Сам скетч открыт на соседней вкладке — его можно менять и запускать заново.
Что попробовать изменить
- Нажмите «Запустить». Плата найдёт датчики на трёх линиях (4, 3 и 4), найдёт ноль ЭРВ и поднимет страницу. Сценарий сам переведёт вправо ползунки протоков fc и fh и переключатель цепи защит ch; без сценария сделайте это мышью, иначе установка не запустится.
- Нажмите кнопку на схеме: насос скважины, через 2 секунды насос тёплого пола, затем компрессор. В журнале будет «ЭРВ N шагов (по таблице)» — стартовая позиция из таблицы по температуре воды из скважины (датчик tc1) и обратке пола (th1), а не константа 220.
- Откройте «Веб-интерфейс» → «Стартовая позиция ЭРВ»: таблица из пяти точек, поправка на обратку и строка «Для текущих условий». Задайте для tc1 другую температуру — число изменится. Смените фреон и нажмите «Заполнить по формуле» — таблица пересчитается.
- Откройте вкладку MQTT симулятора: плата публикует pinport/heatpump/water/state, compressor, pump_well, superheat, subcool, starts_total, protection_chain, current и температуры в temp/… — по значению на тему, с retain.
- Нажмите на деталь tf1 («Всасывание») и поднимите температуру: перегрев вырастет, ЭРВ начнёт открываться. Шаг ПИ зависит от открытия вентиля — выключите «Масштабировать Kp и Ki по открытию» и сравните.
- Переведите ползунок fc влево: через 3 секунды компрессор остановится, в журнале — «проток скважины пропал (… компрессор работал N с)». Верните ползунок и долгим нажатием кнопки сбросьте аварию. Задайте tc1 +4 °C — следующий пуск пойдёт с другой стартовой позицией.
- Крутите ручку pa («Ток компрессора») выше 85 %: через секунду авария по току, реле аварии замкнётся. Ток ниже 15 % после пуска — тоже авария (обрыв, сухой ход).
- Переведите ch влево во время работы: сработала аппаратная защита, в журнале — что видели датчики в этот момент (например, «нет протока скважины»). В «Управление» включите «Прибавка на градус мороза» и задайте tc3 («Улица») −10 °C — цель подачи вырастет.
Смотрите также
- Подключение DS18B20 — Цифровой термометр 1-Wire
- Подключение шагового двигателя — Биполярный шаговый двигатель
- Подключение потенциометра — Переменный резистор
- Подключение ползункового переключателя — Переключатель на три вывода
- Подключение кнопки — Тактовая кнопка
- Подключение модуля реле — Реле для управления нагрузкой
- Подключение автоматического выключателя в щитке — Защита от перегрузки и короткого замыкания
- Подключение контактора — Силовое реле для мощной нагрузки
- Подключение однофазного двигателя 230 В — Двигатель с пусковым или рабочим конденсатором
- Подключение светильника 230 В — Осветительная нагрузка
- Автополив и фитолампа на ESP32 с веб-админкой — То же устройство, что на Arduino Nano — фитолампа 230 В по расписанию и два насоса 12 В со своим периодом полива, — но с…
- Автополив и фитолампа на ESP32: веб-админка и MQTT — Третий вариант прибора: к веб-админке ESP32 добавлен MQTT. Состояние — климат, лампа, насосы, всё расписание — уходит бр…
- Автополив и фитолампа на ESP32: панель на GyverLibs/Settings — Четвёртый вариант прибора: веб-админка не написана руками, а собрана библиотекой GyverLibs/Settings — скетч только переч…
- Тепловой насос вода–вода на ESP32: 11 датчиков DS18B20, ЭРВ и панель Settings — Контроллер теплового насоса вода–вода на базе наружного блока кондиционера: 11 термометров DS18B20 на трёх линиях 1-Wire…
- Тепловой насос воздух–вода на ESP32: оттайка, ЭРВ и панель Settings — Контроллер теплового насоса воздух–вода на базе наружного блока кондиционера: шесть термометров DS18B20 на двух линиях 1…
- Тепловой насос воздух–вода на ESP32, версия 2: таблица ЭРВ, защита по току и MQTT — Вторая версия контроллера теплового насоса воздух–вода по замечаниям пользователей: стартовая позиция ЭРВ из таблицы «ул…
Сборка ещё не запускалась.
Печатайте в Serial числа — они станут графиком.
Serial.println(t); — один ряд,
Serial.printf("t:%.1f h:%.1f\n", t, h); — два с подписями.
Формат тот же, что понимает плоттер Arduino IDE.