#include <WiFi.h>
#include <Preferences.h>
#include <LittleFS.h>
#include <OneWire.h>
#include <DallasTemperature.h>
#include <AccelStepper.h>
#include <SettingsESP.h>
// Тепловой насос воздух–вода на ESP32: контроллер и веб-панель на Settings.
//
// Установка собирается вокруг внешнего блока кондиционера «старт–стоп»
// (компрессор без инвертора): змеевик блока забирает тепло у уличного
// воздуха, а вода тёплого пола греется в пластинчатом теплообменнике —
// конденсаторе. Дроссель — электронный расширительный вентиль (ЭРВ) на
// шаговом двигателе. В отличие от установки вода–вода тут нет скважины,
// зато есть иней на змеевике и потому — оттайка обратным ходом через
// четырёхходовой клапан. Плата
//
// - читает шесть термометров DS18B20 по двум линиям 1-Wire (фреон —
// всасывание, нагнетание и змеевик на D4; вода и воздух — обратка,
// подача и улица на D5);
// - читает два датчика давления фреона (низкое D35, высокое D34);
// - по давлению и таблице фреона считает температуры кипения и
// конденсации и перегрев на всасывании;
// - управляет четырьмя реле: компрессор, вентилятор блока, насос
// тёплого пола и четырёхходовой клапан;
// - держит перегрев ПИ-регулятором, двигая ЭРВ шаговым двигателем;
// - размораживает змеевик: компрессор встаёт, клапан переключается,
// горячий газ идёт в змеевик, по теплу или по времени всё возвращается;
// - охраняет установку: низкое и высокое давление, температура
// нагнетания, нет протока, пропал датчик, оттайка не помогает; пока
// стоит — не даёт замёрзнуть воде в контуре пола;
// - показывает всё на странице Settings (кнопка «Веб-интерфейс»).
//
// Что на схеме в симуляторе, чего нет на железе и наоборот:
// - ЭРВ подключён к плате прямо: для симуляции этого достаточно, для
// железа нужен драйвер — ULN2003/ULN2803 для униполярного ЭРВ на 12 В,
// L298N или DRV8833 для биполярного. Порядок выводов D18, D19, D21,
// D22 — как в AccelStepper(FULL4WIRE, 1, 2, 3, 4).
// - датчики давления — потенциометры на 3,3 В. Настоящие датчики
// (0,5–4,5 В на 5 В питания) вешают на АЦП через делитель 10к/20к;
// коэффициент делителя, диапазон шкалы и калибровка — в панели.
// - датчик протока — движок переключателя: замкнут на землю — вода
// течёт. Настоящий — герконовый или лопастной, контакт на землю.
// - подтяжка 4,7 кОм на линиях 1-Wire в симуляторе есть всегда, на
// железе — по резистору между DATA и 3V3 на каждую из двух линий.
// - катушка четырёхходового клапана на схеме — лампа 20 Вт.
//
// Времена по умолчанию сокращены (SIM_DEMO = 1), чтобы сценарий
// проходил за минуты. Для установки поставьте 0: компрессору будет
// нужно 5 минут паузы между пусками, насосу — минута выбега, а оттайке —
// до десяти минут.
#define SIM_DEMO 1
// --- сеть -------------------------------------------------------------
const char *SSID = "PinPort-GUEST", *PASSWORD = "";
const char *AP_SSID = "HeatPump", *AP_PASSWORD = "heatpump1";
const char *ADMIN_PASSWORD = ""; // пусто — страница без пароля
// --- выводы -----------------------------------------------------------
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_FLOW = 27; // проток тёплого пола: 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_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; byte line; };
const SensorInfo SENSOR[SENSORS] = {
{ "Фреон: всасывание (выход змеевика)", 0 },
{ "Фреон: нагнетание (выход компрессора)", 0 },
{ "Змеевик наружного блока", 0 },
{ "Пол: обратка (вход конденсатора)", 1 },
{ "Пол: подача (выход конденсатора)", 1 },
{ "Улица", 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 };
// --- фреоны -----------------------------------------------------------
// Давление насыщения, бар (абсолютное) × 100, при температуре от −35 до
// +70 °C с шагом 5 градусов. Таблицы — из прежнего проекта, между узлами
// линейная интерполяция. Для смесей (R407C, R404A) — среднее между
// кипением и конденсацией: у них есть температурный глайд.
const byte FREONS = 7;
const char *FREON_NAME = "R22;R410A;R134a;R407C;R404A;R290;R600a";
const byte PT_N = 22;
const int PT_T0 = -35, PT_STEP = 5;
const uint16_t PT[FREONS][PT_N] = {
{ 132, 164, 201, 245, 296, 354, 421, 498, 584, 681, 789, 910, 1044, 1192, 1355, 1534, 1729, 1942, 2174, 2427, 2700, 2996 }, // R22
{ 219, 270, 331, 401, 482, 575, 681, 801, 936, 1088, 1258, 1448, 1657, 1889, 2145, 2426, 2734, 3071, 3440, 3843, 4283, 4670 }, // R410A
{ 66, 85, 107, 133, 164, 201, 243, 293, 350, 414, 488, 572, 665, 770, 887, 1016, 1160, 1318, 1491, 1681, 1889, 2116 }, // R134a
{ 108, 137, 171, 211, 259, 314, 378, 452, 536, 633, 742, 864, 1002, 1156, 1327, 1517, 1728, 1960, 2215, 2496, 2804, 3140 }, // R407C
{ 166, 204, 250, 303, 364, 433, 513, 603, 704, 818, 945, 1086, 1243, 1416, 1606, 1816, 2045, 2295, 2568, 2866, 3189, 3540 }, // R404A
{ 136, 166, 202, 242, 289, 342, 403, 471, 547, 633, 727, 832, 948, 1075, 1214, 1366, 1532, 1711, 1907, 2118, 2346, 2593 }, // R290 (пропан)
{ 37, 47, 59, 73, 89, 109, 132, 158, 188, 222, 261, 304, 353, 408, 469, 536, 610, 692, 781, 879, 986, 1102 }, // R600a (изобутан)
};
// Температура насыщения по абсолютному давлению, бар.
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);
}
// --- настройки --------------------------------------------------------
struct Config {
uint8_t version;
bool enabled; // установка включена
uint8_t mode; // 0 ручной, 1 по подаче, 2 по обратке
uint8_t freon; // номер в таблице
float target, hyst; // целевая температура и ширина гистерезиса, °C
float wcSlope; // погодозависимость: на сколько градусов поднять цель на каждый градус мороза
float minOutdoor; // ниже этой температуры на улице блок не запускаем, °C
float frostOn; // насос гоняет воду, если она остыла ниже, °C
float minLow, maxHigh; // пороги давления (избыточного), бар
float maxDisch; // предел температуры нагнетания и корпуса, °C
float defrostOn, defrostDelta, defrostEnd; // оттайка: змеевик ниже, разница с воздухом, конец, °C
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 eevMin, eevMax, eevStart, eevPre; // eevPre — на сколько открыть ЭРВ перед пуском
char addr[SENSORS][17]; // адреса датчиков, 16 шестнадцатеричных цифр
};
const uint8_t VERSION = 1; // поменяйте — и настройки заполнятся заново
Config cfg;
Preferences prefs;
void setDefaults() {
memset(&cfg, 0, sizeof cfg);
cfg.version = VERSION;
cfg.enabled = false;
cfg.mode = 0;
cfg.freon = 1; // R410A: в кондиционерах чаще всего он
cfg.target = 30; cfg.hyst = 2;
cfg.wcSlope = 0.4; cfg.minOutdoor = -20; cfg.frostOn = 5;
cfg.minLow = 1.0; cfg.maxHigh = 35.0;
cfg.maxDisch = 110;
cfg.defrostOn = -3; cfg.defrostDelta = 8; cfg.defrostEnd = 10; cfg.minWaterDefrost = 12;
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 = 40; cfg.tValve = 4;
#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;
#endif
cfg.retries = 3;
cfg.shTarget = 5; cfg.kp = 4; cfg.ki = 0.6;
cfg.eevMin = 66; cfg.eevMax = 480; cfg.eevStart = 220; cfg.eevPre = 480;
for (byte i = 0; i < SENSORS; i++) strncpy(cfg.addr[i], DEFAULT_ADDR[i], 16);
}
void saveSettings() {
prefs.begin("heatpump", false);
prefs.putBytes("cfg", &cfg, sizeof cfg);
prefs.end();
}
void loadSettings() {
prefs.begin("heatpump", false);
size_t got = prefs.getBytes("cfg", &cfg, sizeof cfg);
prefs.end();
if (got == sizeof cfg && cfg.version == VERSION) return;
setDefaults();
saveSettings();
}
// --- журнал -----------------------------------------------------------
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 *FAULT_NAME[] = {
"нет", "пропал датчик температуры", "неисправен датчик давления", "низкое давление",
"высокое давление", "перегрев нагнетания", "нет протока тёплого пола",
"оттайка не помогает (лёд не тает)",
};
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;
bool flow = false;
void setRelay(byte i, byte pin, bool on) {
if (relayOn[i] == on) return;
relayOn[i] = on;
digitalWrite(pin, on ? RELAY_ON : RELAY_OFF);
}
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;
}
// Проток: контакт на землю, три одинаковых отсчёта подряд.
void readFlow() {
static uint32_t t0 = 0;
static byte n = 0;
if (millis() - t0 < 100) return;
t0 = millis();
n = digitalRead(PIN_FLOW) == LOW ? min(n + 1, 5) : 0;
flow = n >= 3;
}
// --- ЭРВ --------------------------------------------------------------
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; }
}
// Скоростной ПИ: приращение шагов = kp·(ошибка − прошлая) + ki·ошибка·dt.
// Перегрев выше нужного — открываем, ниже — прикрываем.
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; return; }
if (millis() - last < PERIOD) return;
last = millis();
if (isnan(superheat)) return;
float err = superheat - cfg.shTarget;
if (isnan(prevErr)) prevErr = err;
eevTarget += cfg.kp * (err - prevErr) + cfg.ki * err * (PERIOD / 1000.0f);
prevErr = err;
eevTarget = constrain(eevTarget, (float)cfg.eevMin, (float)cfg.eevMax);
eev.moveTo(lroundf(eevTarget));
// В журнал — только заметные сдвиги, иначе он за минуту забит ЭРВ.
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;
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;
}
// Цель с поправкой на погоду: чем холоднее на улице, тем горячее вода.
// Подача рассчитана на 0 °C на улице; slope = 0 отключает поправку.
float targetNow() {
if (isnan(temp[T_OUTDOOR]) || cfg.wcSlope <= 0) return cfg.target;
return cfg.target - cfg.wcSlope * temp[T_OUTDOOR];
}
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);
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);
}
note("аварийный стоп");
}
// Четырёхходовой клапан и вентилятор следуют за состоянием, а не
// включаются по месту: так их положение нельзя забыть при выходе из
// любого состояния, в том числе по аварии.
//
// Клапан в положении «нагрев» в пуске и в работе; в оттайке — обратный
// ход, причём и в паузах вокруг неё: компрессор стоит, клапан ещё
// держится, пока давления выравниваются через открытый ЭРВ.
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);
}
// Вода в контуре пола, пока установка стоит, не должна замёрзнуть.
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.eevStart;
eev.moveTo(cfg.eevStart);
}
break;
}
bool warm = !isnan(temp[T_COIL]) && temp[T_COIL] >= cfg.defrostEnd;
bool late = now - defrostAt >= cfg.defrostMax * 1000UL;
bool cold = !isnan(temp[T_HOT_OUT]) && temp[T_HOT_OUT] < cfg.minWaterDefrost;
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 {
// Вышло время, а змеевик не согрелся: лёд не тает или датчик
// не там. Три раза подряд — это уже неисправность.
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);
eevTarget = cfg.eevStart;
eev.moveTo(cfg.eevStart);
enter(S_RUN);
note("работа после оттайки");
}
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);
}
if (state == S_OFF) {
frostWatch();
setRelay(2, R_PUMP, frostPump);
} else {
frostPump = false;
}
switch (state) {
case S_OFF:
if (!eevHomed || !want) break;
if (now < cfg.tBoot * 1000UL || (stoppedOnce && now - stopAt < cfg.tMinOff * 1000UL)) 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) { raise(F_FLOW); 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);
eevTarget = cfg.eevStart;
eev.moveTo(cfg.eevStart);
enter(S_RUN);
note(String("компрессор включён, давление ") + fmt(pLow) + " / " + fmt(pHigh) + " бар");
}
break;
case S_RUN: {
bool grace = now - stateAt < cfg.tGrace * 1000UL;
if (hFlow.step(!flow, 3000)) { raise(F_FLOW); 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);
note("компрессор выключен");
break;
}
// Иней: змеевик заметно холоднее воздуха и ниже порога. Не чаще
// раза в defrostEvery секунд от пуска или прошлой оттайки.
bool frosty = !isnan(temp[T_COIL]) && temp[T_COIL] < cfg.defrostOn &&
(isnan(temp[T_OUTDOOR]) || temp[T_OUTDOOR] - temp[T_COIL] >= cfg.defrostDelta);
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");
}
break;
}
case S_DEFROST:
if (hFlow.step(!flow, 3000)) { raise(F_FLOW); 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 ? "включено кнопкой" : "выключено кнопкой");
}
}
}
// --- страница: GyverLibs/Settings ---------------------------------------
SettingsESP sett("Тепловой насос");
enum : size_t {
W_STATE = 1, W_FAULT, 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_FOUND = 20, W_T = 30,
};
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 (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 tempText(byte i) {
if (!haveAddr[i]) return "не назначен";
return isnan(temp[i]) ? "нет ответа" : String(temp[i], 1) + " °C";
}
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)) + " %";
}
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.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_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;
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 (b.Select("Фреон", FREON_NAME, &cfg.freon)) changed = true;
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 < 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.endGroup();
}
if (b.beginGroup("Оттайка")) {
if (b.Number("Змеевик ниже, °C", &cfg.defrostOn, -30, 5)) changed = true;
if (b.Number("и холоднее воздуха на, К", &cfg.defrostDelta, 0, 30)) changed = true;
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;
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;
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.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("Адреса датчиков")) {
for (byte l = 0; l < LINES; l++) b.Label(W_FOUND + l, String("На линии ") + (l + 1) + " (D" + PIN_OW[l] + ")", foundText(l));
for (byte i = 0; i < SENSORS; i++) {
if (b.Input(SENSOR[i].name, cfg.addr[i])) changed = true;
}
b.endGroup();
}
b.Log(W_JOURNAL, journal, "Журнал");
if (changed) {
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_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_DEFROST, defrostText());
u.update(W_TARGET, fmt(targetNow()) + " °C");
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, foundText(l));
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);
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());
} 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, 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_FLOW, INPUT_PULLUP);
pinMode(PIN_BUTTON, INPUT_PULLUP);
analogSetPinAttenuation(PIN_P_HIGH, ADC_11db);
analogSetPinAttenuation(PIN_P_LOW, 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();
readFlow();
readTemps();
readPressure();
handleButton();
handleSerial();
control();
eevControl();
eevRun();
if (millis() - blinkAt >= (state == S_FAULT ? 250 : 1000)) {
blinkAt = millis();
digitalWrite(PIN_LED, !digitalRead(PIN_LED));
}
delay(2);
}
Тепловой насос воздух–вода на ESP32: оттайка, ЭРВ и панель Settings
Контроллер теплового насоса воздух–вода на базе наружного блока кондиционера: шесть термометров DS18B20 на двух линиях 1-Wire, два датчика давления фреона, ЭРВ на шаговом двигателе с ПИ-регулированием перегрева, оттайка змеевика четырёхходовым клапаном, погодозависимая цель, защиты и веб-панель на GyverLibs/Settings. Таблицы для семи фреонов.
Что на схеме
Плата 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 - Переключатель «Проток пола» —
fl - Кнопка «Пуск / сброс» —
btn - Реле «Компрессор» —
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, D27, D13, D25, D26, D32, D33. Всего соединений на схеме: 61.
Схема и что в ней написано
Установка воздух–вода на базе наружного блока кондиционера «старт–стоп» (без инвертора). Змеевик блока забирает тепло у уличного воздуха, компрессор сжимает фреон, в пластинчатом теплообменнике — конденсаторе — фреон отдаёт тепло воде тёплого пола, а электронный расширительный вентиль (ЭРВ) на шаговом двигателе дросселирует жидкость обратно к змеевику. От установки вода–вода она отличается тремя вещами: нет скважины и её насоса, есть вентилятор блока, а зимой на змеевике намерзает иней — поэтому есть четырёхходовой клапан и оттайка.
Элементов на плате меньше: шесть термометров вместо одиннадцати, один датчик протока вместо двух, один насос.
Что подключено
- Четыре реле (модуль AC-DC-ESP32-Relay-x4): компрессор (через контактор), вентилятор блока, насос тёплого пола, катушка четырёхходового клапана. На схеме катушка показана лампой 20 Вт с подписью «Катушка клапана» (готовой детали-электромагнита в редакторе нет).
- Два датчика давления фреона (высокое и низкое), ЭРВ на шаговом двигателе, кнопка, датчик протока.
- Шесть DS18B20 на двух линиях: D4 — фреон (всасывание, нагнетание, змеевик наружного блока), D5 — вода и воздух (обратка, подача, улица).
Датчики температуры и адреса
DS18B20 отвечают по одному проводу, и на один вывод их вешают параллельно: у каждого свой 64-битный адрес. На каждую линию нужен один резистор 4,7 кОм между DATA и 3V3 (в симуляторе он есть всегда). Датчик на трубе крепят хомутом с термопастой и закрывают теплоизоляцией — иначе он меряет воздух в подвале. Датчик змеевика прижимают к трубе в середине змеевика, а не к коллектору: от него зависит, когда начнётся и закончится оттайка.
Скетч не полагается на порядок опроса: у каждой роли есть поле с адресом (панель → «Адреса датчиков»). На новой сборке впишите адреса по одному: подержите датчик в руке и посмотрите, какой адрес в списке линии «потеплел». В симуляторе адреса заданы свойством «Адрес» у деталей на схеме.
Давление, кипение и перегрев
Датчики давления 0,5–4,5 В на 5 В питания подключают к входам АЦП через делитель (10 кОм и 20 кОм) — АЦП ESP32 не переносит 5 В. На схеме вместо них потенциометры на 3,3 В; коэффициент делителя, шкалы (17,5 бар на низкой стороне, 45 на высокой) и порог обрыва (меньше 0,3 В) настраиваются в панели.
Давление — это температура насыщения фреона: таблица «давление → температура» для семи хладагентов (R22, R410A, R134a, R407C, R404A, R290, R600a), между узлами интерполяция. Из неё получаются температуры кипения и конденсации, а из кипения и температуры всасывания — перегрев. Цель — 4–7 К: меньше — в компрессор пойдёт жидкость, больше — змеевик работает не на полную. Для смесей (R407C, R404A) есть температурный глайд в 4–7 К, и перегрев по ним надо считать с оглядкой на него.
ЭРВ
Шаговый двигатель ведёт AccelStepper. На старте плата не знает положения вентиля, поэтому прогоняет его в «закрыто» с запасом в 20 шагов на упор (в журнале — «ЭРВ закрыт, положение 0»). Пока насос прогоняет воду перед пуском, вентиль открывается широко (eevPre, по умолчанию на весь ход): давления по обе стороны выравниваются, и компрессору легче стартовать против малого перепада. Сразу после включения компрессора вентиль встаёт на стартовое положение (220 шагов), через tEev секунд включается скоростной ПИ-регулятор: каждые 5 секунд шаг = Kp·(ошибка − прошлая) + Ki·ошибка·5 с. Границы хода (66–480 шагов), Kp, Ki и цель — в панели.
Для настоящего ЭРВ нужен драйвер: ULN2003/ULN2803 для униполярного (5 проводов, 12 В), L298N или DRV8833 для биполярного. Порядок обмоток и число шагов на полный ход — из паспорта ЭРВ.
Пуск, работа, остановка
Порядок пуска: включается насос тёплого пола, ждём проток (не дождались — авария), выдерживаем tPumpBefore секунд, ЭРВ уже открыт для выравнивания давлений — включается компрессор, и вместе с ним вентилятор блока. Стоп: компрессор (не раньше, чем через tMinRun секунд после пуска), ЭРВ закрывается, насос работает ещё tPumpAfter секунд. Между двумя пусками компрессора выдерживается пауза tMinOff. Катушка клапана стоит в положении «нагрев» только пока идёт пуск и работа; за положение отвечает константа VALVE_HEAT_ON — у одних блоков катушка под напряжением в нагреве, у других в охлаждении.
Решение «греть»: всегда (ручной режим), по подаче или по обратке тёплого пола, с гистерезисом. Погодозависимость: цель поднимается на wcSlope градусов на каждый градус мороза (цель 30 °C при 0 °C и 0,4 К/К дают 34 °C при −10 °C). При морозе ниже заданного (по умолчанию −20 °C) блок не запускается: у большинства наружных блоков это нижний предел.
Оттайка
Иней на змеевике — не авария, а рабочее состояние: он растёт, теплообмен падает. Скетч считает змеевик обмёрзшим, если он холоднее порога (−3 °C) и холоднее уличного воздуха на 8 К и больше, и так держится минуту (в симуляторе — 3 секунды). Раньше, чем через defrostEvery секунд после пуска или прошлой оттайки, оттайка не начинается.
Ход оттайки:
- компрессор останавливается, ЭРВ открывается на максимум — давления выравниваются;
- через
tValveсекунд переключается клапан (нагнетание идёт в змеевик), ещё через 3 секунды включается компрессор, вентилятор молчит; - оттайка кончается, когда змеевик прогрелся до +10 °C; или вышло время
defrostMax; или подача тёплого пола остыла ниже порога — оттайка забирает тепло у воды, и лучше прервать её, чем заморозить конденсатор; - компрессор останавливается, пауза, клапан возвращается, компрессор и вентилятор включаются снова.
Три оттайки подряд, закончившиеся по времени, а не по теплу, — это авария «лёд не тает»: сломан клапан, датчик стоит не там или не хватает фреона. Пауза на выравнивание — главное, что защищает компрессор: без неё он запускается против давления и выбивает защиту.
Вода не должна замёрзнуть
Пока установка стоит, а вода в контуре остыла ниже 5 °C, включается насос тёплого пола и гоняет воду, пока она не согреется на 3 градуса. Это не заменяет антифриз в контуре и обогрев наружных труб, но спасает от глупой поломки при выключенной установке. Для уличной части контура применяйте незамерзающую жидкость.
Защиты
- низкое давление — ниже порога дольше 5 с (не проверяется первые
tGraceсекунд после пуска и во время оттайки); - высокое давление — выше порога дольше 1 с;
- перегрев нагнетания — температура нагнетания выше предела дольше 2 с;
- нет протока тёплого пола — при пуске и во время работы;
- пропал датчик (всасывание, нагнетание, змеевик, подача) или датчик давления;
- оттайка не помогает — три неудачные оттайки подряд.
Часть аварий устраняется сама: через tRetry секунд плата пробует снова, до трёх попыток; счёт попыток сбрасывают 10 минут работы без аварий. Нагнетание, датчики и неудачная оттайка остаются до ручного сброса (кнопка на панели, долгое нажатие кнопки на плате, команда reset в порт). Во время аварии часто мигает светодиод на плате.
Контроллер — не единственная защита. Для настоящего компрессора поставьте реле высокого и низкого давления, включённые последовательно в цепь катушки контактора: они отключат компрессор, даже если плата зависла или пропало питание датчика.
Панель Settings
«Сейчас» — состояние, авария, светодиоды реле, проток и ход оттайки. «Управление» — включение, режим, цель с поправкой на погоду, фреон, сброс аварии и аварийный стоп. «Температуры» — все шесть датчиков. «Фреон и ЭРВ» — давления, кипение, конденсация, перегрев, положение вентиля. Дальше — оттайка, защиты, задержки, ЭРВ, датчики давления, адреса датчиков и журнал. Настройки хранятся в NVS и переживают выключение.
Что заменить при сборке
- поменять
SIM_DEMOна 0 — вернутся реальные задержки (пауза 5 минут, выбег 1 минута, оттайка до 10 минут); - поставить драйвер ЭРВ и датчики давления с делителем;
- реле — модуль AC-DC-ESP32-Relay-x4 с выводами 25, 26, 32, 33; если реле включаются низким уровнем, поменять
RELAY_ON; - по схеме своего блока выбрать
VALVE_HEAT_ON; вентилятор блока, если у него свой контроллер, можно оставить на родной плате блока и убрать реле 2; - контактор компрессора и его защиту по мощности подобрать по паспорту блока; в симуляторе это контактор на катушку 230 В и автомат C16.
Что не так в симуляторе
Температуры и давления не связаны друг с другом: тепло от компрессора к воде и к змеевику модель не переносит. Значения задают руками — свойствами деталей и ручками потенциометров. Поэтому оттайка видна в работе (охладите змеевик — она начнётся, нагрейте — закончится), а сама на температуры не влияет.
Библиотеки
Пример собирается с библиотеками:
- WiFi
- Preferences
- LittleFS
- OneWire
- DallasTemperature
- AccelStepper
- Settings
Они уже установлены на сервере сборки — отмечать их в проекте вручную не нужно, список приезжает вместе с примером.
Как это устроено в коде
Основное, чем пользуется скетч:
Preferences— хранение настроек во flash, переживает перезапускStepper— шаги двигателя по фазамmillis()— миллисекунды с запуска: на них строят работу без остановкиSerial.println()— печатает строку в монитор портаdigitalWrite()— выставляет на выводе высокий или низкий уровеньSerial.printf()— печатает в монитор порта с форматированиемanalogRead()— измеряет напряжение на выводе через АЦПconstrain()— зажимает значение в границахdigitalRead()— читает уровень на выводеWiFi.begin()— подключается к точке доступа виртуального Wi-Fidelay()— пауза, на время которой ядро занято ожиданиемWiFi.softAP()— поднимает собственную точку доступа
В скетче 1034 строки; свои функции: satTemp(), setDefaults(), saveSettings(), loadSettings(), note(), setRelay(), addrText(), parseAddr(); подключено заголовков: 7. Сам скетч открыт на соседней вкладке — его можно менять и запускать заново.
Что попробовать изменить
- Нажмите «Запустить». Плата найдёт по три датчика на каждой линии (в мониторе порта) и найдёт ноль ЭРВ. Сценарий сам переведёт ползунок протока fl вправо — «течёт»; без сценария сделайте это мышью, иначе установка не запустится.
- Нажмите кнопку на схеме: включится насос тёплого пола и катушка клапана, через несколько секунд — компрессор и вентилятор блока (реле k1 и k2, контактор на схеме).
- Откройте «Веб-интерфейс»: температуры шести датчиков, давления, кипение и конденсация по таблице R410A, перегрев, положение ЭРВ. Смените фреон на R22 — температуры кипения пересчитаются.
- Нажмите на деталь to («Улица») и задайте +1 °C, затем на te («Змеевик») — −8 °C. Через несколько секунд начнётся оттайка: компрессор встанет, клапан переключится (катушка погаснет), вентилятор замолчит, компрессор пойдёт на горячий газ. Поднимите te до +12 °C — оттайка закончится, всё вернётся.
- Переведите ползунок fl влево: через 3 секунды компрессор остановится, в журнале — «нет протока тёплого пола». Верните ползунок и долгим нажатием кнопки сбросьте аварию.
- Крутите ручку ph выше 80 % — сработает защита по высокому давлению; ph и pl ниже 5 % — обрыв датчика давления.
Смотрите также
- Подключение Шаговый двигатель — Биполярный шаговый двигатель
- Подключение Потенциометр — Переменный резистор
- Подключение Кнопка — Тактовая кнопка
- Подключение Модуль реле — Реле для управления нагрузкой
- Автополив и фитолампа на ESP32 с веб-админкой — То же устройство, что на Arduino Nano — фитолампа 230 В по расписанию и два насоса 12 В со своим периодом полива, — но с…
- Автополив и фитолампа на ESP32: веб-админка и MQTT — Третий вариант прибора: к веб-админке ESP32 добавлен MQTT. Состояние — климат, лампа, насосы, всё расписание — уходит бр…
- Автополив и фитолампа на ESP32: панель на GyverLibs/Settings — Четвёртый вариант прибора: веб-админка не написана руками, а собрана библиотекой GyverLibs/Settings — скетч только переч…
- Тепловой насос вода–вода на ESP32: 11 датчиков DS18B20, ЭРВ и панель Settings — Контроллер теплового насоса вода–вода на базе наружного блока кондиционера: 11 термометров DS18B20 на трёх линиях 1-Wire…
- Сейф с кодом: клавиатура, сервозамок и OLED — Код набирается на клавиатуре, «#» — ввод, «*» — стереть. Верный код отводит ригель сервоприводом на пять секунд. Три оши…
- Светофор с кнопкой пешехода и обратным отсчётом — Машинам горит зелёный, пока никто не нажал кнопку. Нажали — жёлтый, красный, а пешеходам зелёный с отсчётом секунд на ин…
Сборка ещё не запускалась.
Печатайте в Serial числа — они станут графиком.
Serial.println(t); — один ряд,
Serial.printf("t:%.1f h:%.1f\n", t, h); — два с подписями.
Формат тот же, что понимает плоттер Arduino IDE.