#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.
//
// Установка собирается вокруг внешнего блока кондиционера «старт–стоп»
// (компрессор без инвертора): змеевик блока забирает тепло у уличного
// воздуха, а вода тёплого пола греется в пластинчатом теплообменнике —
// конденсаторе. Дроссель — электронный расширительный вентиль (ЭРВ) на
// шаговом двигателе. Есть иней на змеевике и потому — оттайка обратным
// ходом через четырёхходовой клапан. Плата
//
// - читает шесть термометров DS18B20 по двум линиям 1-Wire (фреон —
// всасывание, нагнетание и змеевик на D4; вода и воздух — обратка,
// подача и улица на D5);
// - читает два датчика давления фреона (низкое D35, высокое D34) и ток
// компрессора (D39);
// - по давлению и таблице фреона считает температуры кипения и
// конденсации и перегрев на всасывании;
// - управляет четырьмя реле: компрессор, вентилятор блока, насос
// тёплого пола и четырёхходовой клапан;
// - держит перегрев ПИ-регулятором, двигая ЭРВ шаговым двигателем;
// - размораживает змеевик: компрессор встаёт, клапан переключается,
// горячий газ идёт в змеевик, по теплу или по времени всё возвращается;
// - охраняет установку: низкое и высокое давление, температура
// нагнетания, ток компрессора, нет протока, пропал датчик, оттайка не
// помогает, сработала аппаратная защита; пока стоит — не даёт замёрзнуть
// воде в контуре пола;
// - показывает всё на странице Settings (кнопка «Веб-интерфейс») и
// отдельными значениями публикует в MQTT.
//
// Что нового по сравнению с версией 1 (по замечаниям пользователей):
// 1. Стартовая позиция ЭРВ — из таблицы «уличная температура → шаги» с
// поправкой на обратку пола, а не константа 220. Значения по умолчанию
// считаются по формуле расхода через вентиль, дальше их подстраивают
// руками или самообучением (см. LEARN_K).
// 2. Шаг ПИ масштабируется по текущему открытию: Kp и Ki умножаются на
// (открытие / piRef)^piPow. Включается флагом, чтобы сравнить со
// старым поведением.
// 3. Счётчик пусков компрессора за час, всего и длительность последнего
// цикла — индикатор короткой цикличности и оценки буфера.
// 4. Вход «аппаратная защита сработала» (оптрон на цепи HP, термостатов и
// реле протока): если цепь разомкнулась, в журнале — почему.
// 5. Защита по току компрессора: слишком малый (обрыв, сухой ход) и
// слишком большой; первые секунды после пуска не проверяются.
// 6. MQTT: состояние, перегрев, положение ЭРВ, аварии, ток, пуски и
// температуры — отдельными темами.
// 7. Порог оттайки зависит от мороза: на сильном морозе та же шуба
// инея даёт меньшую разницу «воздух − змеевик».
// 8. Отопительная кривая по точкам «улица → подача» вместо одного наклона.
// 9. Выход аварии (транзистор на лампу или сирену) и вход «все зоны пола
// закрыты» с отдельным разбором потери протока.
// 10. Один фреон для сборки на железе (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 В), вход которого стоит после последнего
// контакта цепи, перед катушкой контактора.
// - вход «зоны закрыты» (D14) — контакты концевиков сервоприводов
// коллектора, замкнуты на землю, когда закрыты все зоны.
// - выход аварии (D17) на схеме — светодиод; на железе — транзистор,
// который включает лампу или сирену.
// - подтяжка 4,7 кОм на линиях 1-Wire в симуляторе есть всегда, на
// железе — по резистору между DATA и 3V3 на каждую из двух линий.
// - катушка четырёхходового клапана на схеме — лампа 20 Вт.
//
// Времена по умолчанию сокращены (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/air";
// --- выводы -----------------------------------------------------------
const byte LINES = 2;
const byte PIN_OW[LINES] = { 4, 5 }; // линии 1-Wire: фреон, вода и воздух
const byte PIN_P_HIGH = 34, PIN_P_LOW = 35; // датчики давления (только вход, АЦП1)
const byte PIN_CUR = 39; // ток компрессора (только вход, АЦП1)
const byte PIN_FLOW = 27; // проток тёплого пола: LOW — течёт
const byte PIN_CHAIN = 16; // цепь защит (оптрон): LOW — замкнута
const byte PIN_ZONES = 14; // концевики сервоприводов: LOW — все зоны закрыты
const byte PIN_ALARM = 17; // выход аварии (транзистор → лампа, сирена): HIGH — авария
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_FAN = 26, R_PUMP = 32, R_VALVE = 33;
// Четырёхходовой клапан: у одних блоков катушка под напряжением в нагреве,
// у других — в охлаждении (оттайке). Смотрите по своему блоку.
const bool VALVE_HEAT_ON = true;
const byte RELAY_ON = HIGH;
const byte RELAY_OFF = RELAY_ON == HIGH ? LOW : HIGH;
// --- датчики температуры ----------------------------------------------
enum { T_SUCTION, T_DISCH, T_COIL,
T_HOT_IN, T_HOT_OUT, T_OUTDOOR, SENSORS };
struct SensorInfo { const char *name; const char *key; byte line; };
const SensorInfo SENSOR[SENSORS] = {
{ "Фреон: всасывание (выход змеевика)", "suction", 0 },
{ "Фреон: нагнетание (выход компрессора)", "discharge", 0 },
{ "Змеевик наружного блока", "coil", 0 },
{ "Пол: обратка (вход конденсатора)", "return", 1 },
{ "Пол: подача (выход конденсатора)", "supply", 1 },
{ "Улица", "outdoor", 1 },
};
// Адреса датчиков на первом запуске. На настоящей установке их
// заменяют своими: страница показывает, что нашлось на каждой линии, и
// адрес вписывается в поле роли. Пустой адрес — роль не назначена.
const char *DEFAULT_ADDR[SENSORS] = {
"280B65BE03AA7EAB",
"280F96A602D6392E",
"2844C32858F45713",
"28139EE317E80A85",
"2880AB7EC084E0A4",
"284C27E812B9C716",
};
// Состояния и аварии. Объявлены выше всех функций: Arduino вставляет
// прототипы перед первой из них, и типам в них надо уже существовать.
enum State : uint8_t { S_OFF, S_PUMP, S_RUN, S_DEFROST, S_COOL, S_FAULT };
enum Fault : uint8_t { F_NONE, F_SENSOR, F_PSENSOR, F_LOW, F_HIGH, F_DISCH, F_FLOW, F_ICE,
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 по обратке
uint8_t freon; // номер в таблице
float target, hyst; // целевая температура и ширина гистерезиса, °C
float wcSlope; // погодозависимость: на сколько градусов поднять цель на каждый градус мороза
bool curveOn; // вместо наклона — кривая по точкам
float curveT[CV_N], curveV[CV_N]; // улица → цель подачи, °C
float minOutdoor; // ниже этой температуры на улице блок не запускаем, °C
float frostOn; // насос гоняет воду, если она остыла ниже, °C
float minLow, maxHigh; // пороги давления (избыточного), бар
float maxDisch; // предел температуры нагнетания и корпуса, °C
float defrostOn, defrostDelta, defrostEnd; // оттайка: змеевик ниже, разница с воздухом, конец, °C
float defrostK, defrostFloor; // разница уменьшается на defrostK за градус мороза, но не ниже defrostFloor
float minWaterDefrost; // оттайку прерываем, если подача остыла ниже, °C
uint16_t defrostEvery, defrostMax, tValve; // оттайка не чаще, не дольше, пауза на клапан, с
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]; // ключ: температура на улице, °C
uint16_t spPos[SP_N]; // шагов
float spRetK, spRetRef; // поправка: шагов на градус обратки выше spRetRef
bool learn; // самообучение таблицы
uint16_t learnTime; // сколько секунд перегрев должен держаться у цели
// Пуски, цепь защит, зоны, ток.
uint8_t maxStarts; // предупреждать, если пусков за час больше (0 — не предупреждать)
bool hwUse, zonesUse;
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_AIR = 8.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кип))
// Это первое приближение, а не паспорт вентиля: настоящая характеристика
// ЭРВ нелинейна, поэтому таблицу после заполнения подстраивают.
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_AIR, 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_AIR;
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.4; cfg.minOutdoor = -20; cfg.frostOn = 5;
cfg.curveOn = false;
const float ct[CV_N] = { -20, -10, 0, 10 };
for (byte i = 0; i < CV_N; i++) { cfg.curveT[i] = ct[i]; cfg.curveV[i] = cfg.target - cfg.wcSlope * ct[i]; }
cfg.minLow = 1.0; cfg.maxHigh = 35.0;
cfg.maxDisch = 110;
cfg.defrostOn = -3; cfg.defrostDelta = 8; cfg.defrostEnd = 10; cfg.minWaterDefrost = 12;
cfg.defrostK = 0.25; cfg.defrostFloor = 3;
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.defrostEvery = 30; cfg.defrostMax = 240; cfg.tValve = 4;
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.defrostEvery = 2700; cfg.defrostMax = 600; cfg.tValve = 30;
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] = { -20, -10, 0, 7, 15 };
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.zonesUse = 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-air2", false);
prefs.putBytes("cfg", &cfg, sizeof cfg);
prefs.end();
dirtyConfig = false;
}
void loadSettings() {
prefs.begin("hp-air2", 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-air2", 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", "pump", "run", "defrost", "cool", "fault" };
const char *FAULT_NAME[] = {
"нет", "пропал датчик температуры", "неисправен датчик давления", "низкое давление",
"высокое давление", "перегрев нагнетания", "нет протока тёплого пола",
"оттайка не помогает (лёд не тает)", "сработала аппаратная защита (цепь разомкнута)",
"ток компрессора слишком мал (обрыв, сухой ход)", "ток компрессора слишком велик",
};
const char *FAULT_ID[] = { "none", "sensor", "psensor", "low", "high", "disch", "flow", "ice",
"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 }; // компрессор, вентилятор, насос, клапан
uint8_t dPhase = 0; // этап оттайки: 0 пауза, 1 оттайка, 2 пауза, 3 возврат
uint32_t defrostAt = 0; // когда началась оттайка (для ограничения по времени)
uint8_t iceFails = 0; // оттаек подряд, кончившихся по времени, а не по теплу
bool frostPump = false; // насос включён против замерзания воды
float temp[SENSORS]; // NAN — датчика нет или не отвечает
byte fails[SENSORS];
float pLow = NAN, pHigh = NAN; // избыточное давление, бар
float tEvap = NAN, tCond = NAN, superheat = NAN;
float amps = NAN; // ток компрессора, А
bool flow = false;
bool chainOk = false; // цепь защит цела
bool zonesClosed = 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 zoneStops = 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]);
DallasTemperature dt0(&ow0), dt1(&ow1);
DallasTemperature *dt[LINES] = { &dt0, &dt1 };
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;
}
// Ток компрессора. Здесь — датчик с выходом 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 inFlow = { PIN_FLOW, false, 0, 0 }, inChain = { PIN_CHAIN, false, 0, 0 }, inZones = { PIN_ZONES, false, 0, 0 };
void readInputs() {
inFlow.step();
inChain.step();
inZones.step();
flow = inFlow.low;
chainOk = inChain.low;
zonesClosed = inZones.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_OUTDOOR])) return cfg.eevStart;
byte i;
float w;
locate(temp[T_OUTDOOR], 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_OUTDOOR])) return;
byte i;
float w;
locate(temp[T_OUTDOOR], 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_OUTDOOR]) + " °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) + " К — возможна нехватка фреона или засор фильтра");
}
}
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, hFlow, hSens, hPSens, hFrost, hChain, hChainIdle, hCurLow, hCurHigh, hDefCold;
bool tempsCritical() {
return !isnan(temp[T_SUCTION]) && !isnan(temp[T_DISCH]) && !isnan(temp[T_COIL]) &&
!isnan(temp[T_HOT_OUT]);
}
bool tooCold() {
return !isnan(temp[T_OUTDOOR]) && temp[T_OUTDOOR] < cfg.minOutdoor;
}
// Кривая «улица → цель подачи» по точкам; за краями — значение крайней.
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;
}
// Цель с поправкой на погоду: чем холоднее на улице, тем горячее вода.
// Или линейно (наклон wcSlope, подача рассчитана на 0 °C на улице;
// slope = 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];
}
// Змеевик «стоит»: за последние 5 с температура сдвинулась меньше чем на 0,25 К.
bool coilSettled() {
static float prev = NAN;
static uint32_t at = 0;
static bool ok = false;
uint32_t now = millis();
if (isnan(temp[T_COIL])) { prev = NAN; return true; }
if (isnan(prev) || now - at >= 5000) {
ok = !isnan(prev) && fabsf(temp[T_COIL] - prev) < 0.25f;
prev = temp[T_COIL];
at = now;
}
return ok;
}
// Разница «воздух − змеевик», с которой считаем змеевик обмёрзшим. На
// морозе та же шуба инея даёт меньшую разницу, поэтому порог снижается
// на defrostK за каждый градус мороза, но не ниже defrostFloor.
float defrostDeltaNow() {
float d = cfg.defrostDelta;
if (!isnan(temp[T_OUTDOOR]) && temp[T_OUTDOOR] < 0 && cfg.defrostK > 0)
d -= cfg.defrostK * (-temp[T_OUTDOOR]);
return max(d, min(cfg.defrostFloor, cfg.defrostDelta));
}
bool demand() {
if (!cfg.enabled) { heat = false; return false; }
if (tooCold()) { heat = false; return false; }
if (cfg.mode == 0) { heat = true; return true; }
float v = cfg.mode == 1 ? temp[T_HOT_OUT] : temp[T_HOT_IN];
if (isnan(v)) return heat; // датчика нет — держим прежнее решение, а о датчике скажет защита
float t = 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_FLOW;
}
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;
iceFails = 0;
enter(S_OFF);
note(String("авария сброшена (") + who + ")");
}
void emergencyStop() {
cfg.enabled = false;
saveSettings();
if (state == S_RUN || state == S_PUMP || state == S_DEFROST) {
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 (!flow) s += "нет протока тёплого пола; ";
if (!isnan(temp[T_HOT_OUT]) && temp[T_HOT_OUT] < cfg.frostOn + 1)
s += String("подача ") + fmt(temp[T_HOT_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(const char *where) {
uint32_t ran = relayOn[0] ? (millis() - compOnAt) / 1000 : 0;
if (cfg.zonesUse && zonesClosed) {
note(String("проток пропал (") + where + ", компрессор работал " + ran + " с): все зоны пола закрыты — остановка без аварии");
zoneStops++;
if (relayOn[0]) {
setRelay(0, R_COMP, false);
stopAt = millis();
stoppedOnce = true;
eevClose();
enter(S_COOL);
persistIfDirty();
} else {
setRelay(2, R_PUMP, false);
eevClose();
enter(S_OFF);
}
return;
}
note(String("проток пропал (") + where + ", компрессор работал " + ran + " с), зоны не закрыты — проверьте насос и фильтр");
raise(F_FLOW);
}
// Четырёхходовой клапан и вентилятор следуют за состоянием, а не
// включаются по месту: так их положение нельзя забыть при выходе из
// любого состояния, в том числе по аварии.
//
// Клапан в положении «нагрев» в пуске и в работе; в оттайке — обратный
// ход, причём и в паузах вокруг неё: компрессор стоит, клапан ещё
// держится, пока давления выравниваются через открытый ЭРВ.
void updateOutputs() {
bool reverse = state == S_DEFROST && (dPhase == 1 || dPhase == 2);
bool heatPos = state == S_PUMP || state == S_RUN || (state == S_DEFROST && !reverse);
setRelay(3, R_VALVE, VALVE_HEAT_ON ? heatPos : reverse);
// Вентилятор дует, пока идёт компрессор и нет оттайки: горячий газ в
// змеевике надо не студить, а пускать на лёд.
setRelay(1, R_FAN, relayOn[0] && !reverse);
digitalWrite(PIN_ALARM, state == S_FAULT ? HIGH : LOW);
}
// Вода в контуре пола, пока установка стоит, не должна замёрзнуть.
void frostWatch() {
float w = NAN;
for (byte i : { (byte)T_HOT_IN, (byte)T_HOT_OUT })
if (!isnan(temp[i]) && (isnan(w) || temp[i] < w)) w = temp[i];
if (isnan(w)) return;
bool was = frostPump;
if (!frostPump && w < cfg.frostOn) frostPump = true;
else if (frostPump && w > cfg.frostOn + 3) frostPump = false;
if (was != frostPump) note(frostPump ? "вода в контуре остыла — насос включён против замерзания" : "вода согрелась — насос выключен");
}
const uint32_t SETTLE = 3000; // клапану нужно время встать в положение
// Оттайка: компрессор останавливается, ЭРВ открывается, давления
// выравниваются; клапан переключается; компрессор идёт на горячий газ,
// пока змеевик не согреется или не выйдет время; всё в обратном порядке.
void defrost(uint32_t now, bool wanted) {
uint32_t el = now - stateAt;
switch (dPhase) {
case 0:
setRelay(0, R_COMP, false);
if (el >= cfg.tValve * 1000UL) {
dPhase = 1; stateAt = now; defrostAt = now;
note("оттайка: клапан переключён на обратный ход");
}
break;
case 1: {
if (!relayOn[0]) {
if (el >= SETTLE) {
eev.enableOutputs();
setRelay(0, R_COMP, true);
// в обратном ходу вентиль открыт широко: пластинам нужно кипеть, а змеевику греться
eevTarget = cfg.eevMax;
eev.moveTo(cfg.eevMax);
}
break;
}
bool warm = !isnan(temp[T_COIL]) && temp[T_COIL] >= cfg.defrostEnd;
bool late = now - defrostAt >= cfg.defrostMax * 1000UL;
// секундный провал подачи при переключении клапана (пластины на миг становятся испарителем) — не повод бросать оттайку
bool cold = hDefCold.step(!isnan(temp[T_HOT_OUT]) && temp[T_HOT_OUT] < cfg.minWaterDefrost, 5000);
if (!warm && !late && !cold) break;
if (warm) { iceFails = 0; note("оттайка закончена: змеевик " + fmt(temp[T_COIL]) + " °C"); }
else if (cold) note("оттайка прервана: подача остыла до " + fmt(temp[T_HOT_OUT]) + " °C");
else if (!isnan(temp[T_COIL]) && temp[T_COIL] > 2.0f) {
// Змеевик ушёл с плато таяния (0 °C): лёд сошёл, а до уставки не нагрелся — на сильном морозе
// горячий газ отдаёт тепло воздуху. Это не неудача.
iceFails = 0;
note("оттайка закончена по времени: змеевик " + fmt(temp[T_COIL]) + " °C");
}
else {
// Вышло время, а змеевик не согрелся: лёд не тает или датчик
// не там. Три раза подряд — это уже неисправность.
note("оттайка: время вышло, змеевик " + fmt(temp[T_COIL]) + " °C");
if (++iceFails >= 3) { raise(F_ICE); return; }
}
setRelay(0, R_COMP, false);
eevTarget = cfg.eevMax;
eev.moveTo(cfg.eevMax);
dPhase = 2; stateAt = now;
break;
}
case 2:
if (el >= cfg.tValve * 1000UL) { dPhase = 3; stateAt = now; }
break;
default:
if (el >= SETTLE) {
setRelay(0, R_COMP, true);
float sp = startPos();
eevTarget = sp;
eev.moveTo(lroundf(sp));
enter(S_RUN);
note(String("работа после оттайки, ЭРВ ") + lroundf(sp) + " шагов");
}
break;
}
}
void control() {
uint32_t now = millis();
bool want = demand();
// Защиты, не зависящие от состояния: давление и датчики давления —
// пока стоит компрессор, они показывают давление в покое.
bool running = state == S_RUN || (state == S_DEFROST && dPhase == 1 && relayOn[0]);
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(!isnan(temp[T_DISCH]) && temp[T_DISCH] > cfg.maxDisch, 2000)) raise(F_DISCH);
}
// Цепь аппаратной защиты (HP, термостаты, реле протока). Разомкнулась,
// пока установка работала или запускалась, — компрессор уже отпущен
// контактором; контроллеру остаётся честно записать причину.
bool chainWatch = state == S_PUMP || state == S_RUN || state == S_DEFROST;
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);
if (state == S_OFF) {
frostWatch();
setRelay(2, R_PUMP, frostPump);
} else {
frostPump = false;
}
switch (state) {
case S_OFF: {
// Готовы пускать (кроме цепи защит): тогда разомкнутая цепь — причина,
// почему пуска нет, и о ней надо сказать, а не молчать.
bool ready = eevHomed && want &&
!(now < cfg.tBoot * 1000UL || (stoppedOnce && now - stopAt < cfg.tMinOff * 1000UL)) &&
!(cfg.zonesUse && zonesClosed); // все зоны закрыты — греть некуда
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(2, R_PUMP, true);
eev.enableOutputs();
// Перед пуском ЭРВ открывается широко (eevPre): давления по сторонам
// выравниваются, и компрессору легче стартовать. На стартовое
// положение вентиль встаёт уже после включения компрессора.
eevTarget = min(cfg.eevPre, cfg.eevMax);
eev.moveTo(lroundf(eevTarget));
enter(S_PUMP);
note("запуск: насос тёплого пола включён");
break;
}
case S_PUMP:
if (!flow && now - stateAt >= cfg.tFlow * 1000UL) { flowLost("при пуске"); break; }
if (!want) { setRelay(2, R_PUMP, false); eevClose(); enter(S_OFF); note("запрос снят, насос выключен"); break; }
if (flow && 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 (hFlow.step(!flow, 3000)) { flowLost("в работе"); break; }
if (!grace && hLow.step(pLow < cfg.minLow, 5000)) { raise(F_LOW); 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;
}
// Иней: змеевик заметно холоднее воздуха и ниже порога. Порог разницы
// зависит от мороза. Не чаще раза в defrostEvery секунд от пуска или
// прошлой оттайки.
// Инея не бывает в первые минуты после пуска и оттайки: пока перегрев не вышел к цели, а змеевик
// быстро остывает или греется, большую «разницу» дают переходные процессы, а не шуба.
bool settled = isnan(superheat) || (fabsf(superheat - cfg.shTarget) < 2.0f && coilSettled());
bool frosty = settled && !isnan(temp[T_COIL]) && temp[T_COIL] < cfg.defrostOn &&
(isnan(temp[T_OUTDOOR]) || temp[T_OUTDOOR] - temp[T_COIL] >= defrostDeltaNow());
if (hFrost.step(frosty && now - stateAt >= cfg.defrostEvery * 1000UL, SIM_DEMO ? 3000 : 60000)) {
dPhase = 0;
eevTarget = cfg.eevMax;
eev.moveTo(cfg.eevMax);
enter(S_DEFROST);
note(String("оттайка: змеевик ") + fmt(temp[T_COIL]) + " °C, воздух " + fmt(temp[T_OUTDOOR]) +
" °C, порог разницы " + fmt(defrostDeltaNow()) + " К");
}
break;
}
case S_DEFROST:
if (hFlow.step(!flow, 3000)) { flowLost("в оттайке"); break; }
defrost(now, want);
break;
case S_COOL:
if (now - stateAt >= cfg.tPumpAfter * 1000UL) {
setRelay(2, R_PUMP, false);
enter(S_OFF);
note("насос выключен");
}
break;
case S_FAULT:
// Насос после аварии по давлению и температуре ещё крутится —
// унести тепло; при пропавшем протоке — сразу стоп, он и так сухой.
if (now - faultAt >= cfg.tPumpAfter * 1000UL || fault == F_FLOW) setRelay(2, R_PUMP, false);
if (recoverable(fault) && retryCount < cfg.retries && now - faultAt >= cfg.tRetry * 1000UL) {
retryCount++;
fault = F_NONE;
enter(S_OFF);
note(String("повторный пуск, попытка ") + retryCount);
}
break;
}
updateOutputs();
}
// --- кнопка -----------------------------------------------------------
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_FAN, M_PUMP, M_VALVE, M_SUPERHEAT, M_TEVAP, M_TCOND,
M_PLOW, M_PHIGH, M_EEV, M_STARTS_H, M_STARTS_T, M_CURRENT, M_CHAIN, M_FLOW, M_ZONES, M_T0,
M_ITEMS = M_T0 + SENSORS
};
const char *MQ_NAME[M_T0] = {
"state", "fault", "fault_text", "warning", "compressor", "fan", "pump", "valve", "superheat", "t_evap", "t_cond",
"p_low", "p_high", "eev_steps", "starts_hour", "starts_total", "current", "protection_chain", "flow", "zones_closed",
};
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_FAN: return onOff(relayOn[1]);
case M_PUMP: return onOff(relayOn[2]);
case M_VALVE: return onOff(relayOn[3]);
case M_SUPERHEAT: return numText(superheat);
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: return String(flow ? "yes" : "no");
case M_ZONES: return String(zonesClosed ? "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-air";
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_FAN, W_LED_PUMP, W_LED_VALVE, W_JOURNAL,
W_PLOW, W_PHIGH, W_TEVAP, W_TCOND, W_SH, W_EEV, W_FLOW, W_DEFROST, W_TARGET,
W_CHAIN, W_ZONES, W_CUR, W_STARTS_H, W_STARTS_T, W_CYCLE, W_PIF, W_STARTPOS, W_LEARNED, W_DDELTA, 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 (tooCold()) t += ", на улице слишком холодно";
else if (cfg.zonesUse && zonesClosed) t += ", все зоны пола закрыты";
else 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 defrostText() {
static const char *PH[] = { "пауза перед клапаном", "горячий газ в змеевик", "пауза перед возвратом", "клапан возвращается" };
if (state == S_DEFROST) return String(PH[dPhase]) + ", " + (millis() - stateAt) / 1000 + " с";
if (state != S_RUN) return "—";
long left = (long)cfg.defrostEvery - (long)((millis() - stateAt) / 1000);
if (left > 0) return String("не раньше чем через ") + left + " с";
return "можно, если появится иней";
}
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_FAN, "Вентилятор блока", relayOn[1], sets::Colors::Gray, sets::Colors::Green);
b.LED(W_LED_PUMP, "Насос тёплого пола", relayOn[2], sets::Colors::Gray, sets::Colors::Green);
b.LED(W_LED_VALVE, "Катушка 4-ходового клапана", relayOn[3], sets::Colors::Gray, sets::Colors::Yellow);
b.Label(W_FLOW, "Проток тёплого пола", flow ? "есть" : "нет");
b.Label(W_ZONES, "Зоны пола", cfg.zonesUse ? (zonesClosed ? String("все закрыты") : String("открыты")) : String("не контролируются"));
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.Label(W_DEFROST, "Оттайка", defrostText());
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("Цель при 0 °C на улице, °C", &cfg.target, 5, 60)) changed = true;
if (b.Number("Прибавка на градус мороза, К", &cfg.wcSlope, 0, 2)) changed = true;
if (b.Switch("Кривая по точкам вместо наклона", &cfg.curveOn)) changed = true;
b.Label(W_TARGET, "Цель сейчас", fmt(targetNow()) + " °C");
if (b.Number("Гистерезис, °C", &cfg.hyst, 0.5, 10)) changed = true;
if (b.Number("Не запускать ниже, °C на улице", &cfg.minOutdoor, -40, 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_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], -40, 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("Змеевик ниже, °C", &cfg.defrostOn, -30, 5)) changed = true;
if (b.Number("и холоднее воздуха на, К (при 0 °C)", &cfg.defrostDelta, 0, 30)) changed = true;
if (b.Number("Меньше на К за каждый градус мороза", &cfg.defrostK, 0, 2)) changed = true;
if (b.Number("Но не меньше, К", &cfg.defrostFloor, 0, 30)) changed = true;
b.Label(W_DDELTA, "Порог разницы сейчас", fmt(defrostDeltaNow()) + " К");
if (b.Number("Конец, когда змеевик выше, °C", &cfg.defrostEnd, 2, 30)) changed = true;
if (b.Number("Не чаще, с", &cfg.defrostEvery, 10, 7200)) changed = true;
if (b.Number("Не дольше, с", &cfg.defrostMax, 10, 1800)) changed = true;
if (b.Number("Пауза на клапан, с", &cfg.tValve, 3, 300)) changed = true;
if (b.Number("Прервать, если подача холоднее, °C", &cfg.minWaterDefrost, 0, 40)) changed = true;
if (b.Number("Насос против замерзания ниже, °C", &cfg.frostOn, 1, 15)) changed = true;
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.Spinner("Попыток после аварии", 0, 9, 1, &cfg.retries)) changed = true;
if (b.Switch("Контролировать цепь аппаратной защиты", &cfg.hwUse)) changed = true;
if (b.Switch("Контролировать «все зоны закрыты»", &cfg.zonesUse)) 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_FAN, relayOn[1]);
u.update(W_LED_PUMP, relayOn[2]);
u.update(W_LED_VALVE, relayOn[3]);
u.update(W_FLOW, flow ? "есть" : "нет");
u.update(W_ZONES, cfg.zonesUse ? (zonesClosed ? String("все закрыты") : String("открыты")) : String("не контролируются"));
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_DEFROST, defrostText());
u.update(W_TARGET, fmt(targetNow()) + " °C");
u.update(W_DDELTA, fmt(defrostDeltaNow()) + " К");
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_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-air");
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\n",
fmt(pLow).c_str(), fmt(pHigh).c_str(), fmt(tEvap).c_str(), fmt(tCond).c_str(),
fmt(superheat).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_FAN, R_PUMP, R_VALVE };
for (byte r : relays) { pinMode(r, OUTPUT); digitalWrite(r, RELAY_OFF); }
pinMode(PIN_LED, OUTPUT);
pinMode(PIN_ALARM, OUTPUT);
digitalWrite(PIN_ALARM, LOW);
pinMode(PIN_FLOW, INPUT_PULLUP);
pinMode(PIN_CHAIN, INPUT_PULLUP);
pinMode(PIN_ZONES, 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) «Всасывание» —
ts, температура 8 °C - DS18B20 (термометр 1-Wire) «Нагнетание» —
td, температура 80 °C - DS18B20 (термометр 1-Wire) «Змеевик» —
te, температура 2 °C - DS18B20 (термометр 1-Wire) «Обратка» —
tr, температура 28 °C - DS18B20 (термометр 1-Wire) «Подача» —
tp, температура 33 °C - DS18B20 (термометр 1-Wire) «Улица» —
to, температура 5 °C - Шаговый двигатель «ЭРВ» —
eev - Потенциометр «Давл. высокое» —
ph - Потенциометр «Давл. низкое» —
pl - Потенциометр «Ток компрессора» —
pa - Переключатель «Проток пола» —
fl - Переключатель «Цепь защит» —
ch - Переключатель «Зоны закрыты» —
zn - Кнопка «Пуск / сброс» —
btn - Светодиод «Авария» —
la, цвет красный - Резистор «330 Ом» —
ra, номинал 330 Ом - Реле «Компрессор» —
k1 - Реле «Вентилятор» —
k2 - Реле «Насос пола» —
k3 - Реле «4-ход. клапан» —
k4 - Ввод 230 В «Ввод 230 В» —
in - Автоматический выключатель «Цепи упр.» —
qf1 - Автоматический выключатель «Компрессор» —
qf2 - Контактор «Контактор» —
km1 - Двигатель однофазный «Компрессор» —
mc - Двигатель однофазный «Вентилятор» —
mf - Двигатель однофазный «Насос пола» —
mw - Светильник 230 В «Катушка клапана» —
la1
Задействованы выводы платы: 3V3, GND, D4, D5, D18, D19, D21, D22, D34, D35, VN, D27, D16, D14, D13, D17, D25, D26, D32, D33. Всего соединений на схеме: 71.
Что это и чем отличается от первой версии
Второй вариант установки воздух–вода на базе наружного блока кондиционера «старт–стоп»: змеевик блока забирает тепло у уличного воздуха, компрессор сжимает фреон, в пластинчатом теплообменнике — конденсаторе — фреон отдаёт тепло воде тёплого пола, электронный расширительный вентиль (ЭРВ) на шаговом двигателе дросселирует жидкость обратно к змеевику. Первую версию мы оставили в галерее («Тепловой насос воздух–вода на ESP32: оттайка, ЭРВ и панель Settings»); эта собрана по замечаниям тех, кто её повторял. Что изменилось:
- Стартовая позиция ЭРВ — из таблицы, а не константа 220 шагов. Ключ — температура на улице, поправка — температура обратки пола.
- Шаг ПИ зависит от открытия вентиля (флаг в панели).
- Счётчик пусков за час и за всё время, длительность последнего цикла.
- Вход «цепь аппаратной защиты»: если цепь HP/термостатов разомкнулась, в журнале — что видели датчики в этот момент.
- Защита по току компрессора: слишком малый ток (обрыв, сухой ход) и слишком большой.
- MQTT отдельными темами, по значению на тему.
- Порог оттайки зависит от мороза.
- Отопительная кривая по четырём точкам вместо одного наклона.
- Выход аварии и вход «все зоны пола закрыты» с отдельным разбором потери протока.
- Один фреон для сборки на железе и обновление по воздуху — константами в начале скетча.
Всё остальное — шесть DS18B20 на двух линиях, давления, оттайка четырёхходовым клапаном, защита воды от замерзания, панель Settings — как в первой версии; о нём подробно написано в её статье.
Стартовая позиция ЭРВ: таблица, формула и самообучение
Сразу после пуска компрессора и после оттайки вентиль надо поставить в положение, близкое к рабочему: от этого зависит, сколько жидкости уйдёт в змеевик в первые минуты. Константа подходит одной погоде. Поэтому положение берётся из таблицы из пяти точек «температура на улице → шагов», между точками — линейная интерполяция, за краями — значение крайней точки (не экстраполяция). Сверху добавляется поправка на обратку пола: чем она горячее, тем выше давление конденсации и тем больше нужно прикрыть вентиль (шагов на градус выше опорной обратки 30 °C).
Откуда берутся числа по умолчанию. Из формулы расхода: вентиль пропускает тем больше, чем больше перепад давления, а нужен ему расход компрессора, который растёт с давлением кипения:
шагов = eevStart · (Pкип / Pкип0) · √((Pконд0 − Pкип0) / (Pконд − Pкип))
Опора — кипение 0 °C, конденсация 40 °C, 220 шагов. Кипение принимается на 8 К ниже уличного воздуха, конденсация — на 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 А). Выход за предел — авария, которая сама не повторяется. Все три числа — в панели.
Оттайка: порог зависит от мороза
Змеевик считается обмёрзшим, если он холоднее порога (−3 °C) и холоднее уличного воздуха на 8 К. На сильном морозе та же шуба инея даёт меньшую разницу — воздух и так холодный, — поэтому порог снижается на 0,25 К за каждый градус мороза, но не ниже 3 К: при −12 °C это 5 К. Все три числа — в панели («Оттайка»). Ход оттайки, паузы на выравнивание давлений и защита воды те же, что в первой версии.
Все зоны закрыты — это не авария насоса
Термоголовки на коллекторе пола закрываются, когда в комнатах тепло. Когда закрыты все, насос гонит воду в тупик, проток пропадает — и первая версия объявляла аварию. Теперь вход D14 (zn: замкнут — все сервоприводы закрыты) отделяет эту ситуацию от неисправности насоса: компрессор останавливается без аварии, в журнал идёт «проток пропал (… компрессор работал N с): все зоны пола закрыты — остановка без аварии», а пока зоны закрыты, пуска нет и панель говорит почему. Открылась хоть одна — установка запускается сама. Если проток пропал, а зоны открыты, — это насос или фильтр, и тут уже авария; в журнале время работы до потери протока. Для настоящей установки к тому же нужен перепускной клапан (байпас) между подачей и обраткой: он оставляет минимальный проток при закрытых зонах.
Отопительная кривая и выход аварии
Цель подачи считается по наклону (на сколько поднять цель на каждый градус мороза) или по четырём точкам «улица → подача» — переключатель на панели. Кривая по умолчанию совпадает с наклоном 0,4 К/К, поэтому переход неощутим; редактируйте точки, когда увидите, что при −20 °C полу нужно не 38 °C, а 41.
Выход аварии D17 — светодиод с резистором 330 Ом. Для лампы или сирены на железе между выводом и нагрузкой нужен транзистор (например, IRLZ44N с резистором на затворе и диодом на катушке сирены); вывод платы нагрузку не потянет. Включён, пока идёт авария.
MQTT: по значению на тему
Каждое значение публикуется в свою тему pinport/heatpump/air/<имя> с флагом retain: state, fault, fault_text, compressor, fan, pump, valve, superheat, t_evap, t_cond, p_low, p_high, eev_steps, starts_hour, starts_total, current, protection_chain, flow, zones_closed, а температуры — в temp/suction, temp/discharge, temp/coil, temp/return, temp/supply, temp/outdoor. Так 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; на время обновления установка останавливается. В симуляторе не проверяется.VALVE_HEAT_ON— по схеме своего блока.- Датчики давления через делитель, драйвер ЭРВ (ULN2003/ULN2803 или L298N/DRV8833), реле — модуль AC-DC-ESP32-Relay-x4 с выводами 25, 26, 32, 33.
Что проверено и что нет
Логика скетча — таблица и интерполяция, самообучение, запись во флеш только при остановке, масштаб ПИ, счётчик пусков, цепь защит, ток, зоны, порог оттайки, кривая, темы MQTT, поведение без брокера, переход millis() через 2³² — прогнана на хосте с подставными библиотеками и виртуальным временем; сценарий этой страницы — тоже. Прошивка под ESP32 и сам PinPort в этих прогонах не участвовали: если что-то на вашей схеме ведёт себя иначе, пришлите журнал из порта. На железе не проверялись ни OTA, ни PZEM, ни транзисторный выход аварии.
Что не так в симуляторе
Пока галочка «запускать со сценарием» снята, датчики и ток вам не принадлежат: ими управляет модель фреонового контура (вкладка «Контур»). Реле платы включают компрессор, вентилятор, насос пола и четырёхходовой клапан (катушка под током — нагрев), шаги ЭРВ открывают клапан, а давления, температуры и ток компрессора возвращаются в датчики. Поэтому ПИ в симуляторе сходится: перегрев откликается на положение вентиля, а самообучение запоминает настоящий результат, а не граничное положение. На морозе змеевик обмерзает, и скетч размораживает его клапаном — лёд тает за счёт тепла, которое установка отбирает у воды.
Рычагов на вкладке три: температура на улице, заправка и засор фильтра. Утечка заставит ЭРВ уйти на максимум и даст предупреждение о фреоне. Ручки потенциометров давления и тока и температуры датчиков при включённой модели ничего не решают: их перезаписывает модель. Со сценарием она молчит: сценарий задаёт те же датчики по своим строкам.
Модель учебная: сосредоточенные параметры, температурный глайд смесей не считается, размер установки подогнан под компрессор 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()— пауза, на время которой ядро занято ожиданием
В скетче 1815 строк; свои функции: satTemp(), satPress(), formulaPos(), fillTable(), setDefaults(), sortTables(), saveSettings(), loadSettings(); подключено заголовков: 9. Сам скетч открыт на соседней вкладке — его можно менять и запускать заново.
Что попробовать изменить
- Нажмите «Запустить». Плата найдёт по три датчика на каждой линии и найдёт ноль ЭРВ. Сценарий сам переведёт вправо ползунок протока fl и переключатель цепи защит ch («течёт» и «цепь цела»); без сценария сделайте это мышью, иначе установка не запустится — разомкнутая цепь перед пуском это тоже авария, и журнал скажет, почему.
- Нажмите кнопку на схеме: включится насос тёплого пола и катушка клапана, через несколько секунд — компрессор и вентилятор блока. В журнале будет «ЭРВ N шагов (по таблице)» — стартовая позиция из таблицы по уличной температуре и обратке пола, а не константа 220.
- Откройте «Веб-интерфейс» → «Стартовая позиция ЭРВ»: таблица из пяти точек, поправка на обратку и строка «Для текущих условий». Задайте для to («Улица») другую температуру — число изменится. Смените фреон и нажмите «Заполнить по формуле» — таблица пересчитается.
- Откройте вкладку MQTT симулятора: плата публикует pinport/heatpump/air/state, compressor, superheat, starts_total, protection_chain, current и температуры в temp/… — по значению на тему, с retain.
- Мороз и оттайка: задайте to −12 °C, затем te («Змеевик») −18 °C. Разница с воздухом 6 К; в первой версии порог был 8 К, и оттайки бы не было, теперь на морозе он 5 К. Поднимите te до +12 °C — оттайка закончится.
- Переведите вправо переключатель zn («Зоны закрыты») и влево ползунок fl: проток пропал, но все зоны закрыты — компрессор останавливается без аварии, пуска нет, пока зоны закрыты. Верните оба — установка запустится сама.
- Крутите ручку pa («Ток компрессора») выше 85 %: через секунду авария по току, загорается светодиод la («Авария»). Долгое нажатие кнопки сбрасывает аварию. Ток ниже 15 % после пуска — тоже авария (обрыв, сухой ход).
- Переведите ch влево во время работы: сработала аппаратная защита, в журнале — что видели датчики в этот момент (или «причина не видна»). Если до этого поднять ph примерно до 70 % (около 33 бар — ещё не авария по давлению), в причине будет «высокое давление».
Смотрите также
- Подключение 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, версия 2: таблица ЭРВ по скважине, защита по току и MQTT — Вторая версия контроллера теплового насоса вода–вода по замечаниям пользователей: стартовая позиция ЭРВ из таблицы «вода…
- Тепловой насос воздух–вода на ESP32: оттайка, ЭРВ и панель Settings — Контроллер теплового насоса воздух–вода на базе наружного блока кондиционера: шесть термометров DS18B20 на двух линиях 1…
Сборка ещё не запускалась.
Печатайте в Serial числа — они станут графиком.
Serial.println(t); — один ряд,
Serial.printf("t:%.1f h:%.1f\n", t, h); — два с подписями.
Формат тот же, что понимает плоттер Arduino IDE.