#include <WiFi.h>
#include <Preferences.h>
#include <LittleFS.h>
#include <OneWire.h>
#include <DallasTemperature.h>
#include <AccelStepper.h>
#include <SettingsESP.h>
// Тепловой насос вода–вода на ESP32: контроллер и веб-панель на Settings.
//
// Установка собирается вокруг внешнего блока кондиционера «старт–стоп»
// (компрессор без инвертора): скважина отдаёт тепло в испаритель,
// конденсатор греет воду тёплого пола, дроссель — электронный
// расширительный вентиль (ЭРВ) на шаговом двигателе. Плата
//
// - читает 11 термометров DS18B20 по трём линиям 1-Wire (фреон — 4 датчика
// на D4, скважина — 3 на D5, тёплый пол и помещение — 4 на D23);
// - читает два датчика давления фреона (низкое 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 на каждую из трёх линий.
//
// Времена по умолчанию сокращены (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 PIN_OW[3] = { 4, 5, 23 }; // линии 1-Wire: фреон, скважина, пол
const byte PIN_P_HIGH = 34, PIN_P_LOW = 35; // датчики давления (только вход, АЦП1)
const byte PIN_FLOW_COLD = 14, PIN_FLOW_HOT = 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_COLD = 26, R_HOT = 32, R_ALARM = 33;
const byte RELAY_ON = HIGH;
const byte RELAY_OFF = RELAY_ON == HIGH ? LOW : HIGH;
// --- датчики температуры ----------------------------------------------
enum { T_SUCTION, T_DISCH, T_LIQUID, T_EVAPIN,
T_COLD_IN, T_COLD_OUT, T_OUTDOOR,
T_HOT_IN, T_HOT_OUT, T_ROOM, T_COMP, SENSORS };
struct SensorInfo { const char *name; byte line; };
const SensorInfo SENSOR[SENSORS] = {
{ "Фреон: всасывание (выход испарителя)", 0 },
{ "Фреон: нагнетание (выход компрессора)", 0 },
{ "Фреон: жидкость (выход конденсатора)", 0 },
{ "Фреон: вход испарителя (после ЭРВ)", 0 },
{ "Скважина: вход испарителя", 1 },
{ "Скважина: выход испарителя", 1 },
{ "Улица", 1 },
{ "Пол: обратка (вход конденсатора)", 2 },
{ "Пол: подача (выход конденсатора)", 2 },
{ "Помещение", 2 },
{ "Корпус компрессора", 2 },
};
// Адреса датчиков на первом запуске. На настоящей установке их
// заменяют своими: страница показывает, что нашлось на каждой линии, и
// адрес вписывается в поле роли. Пустой адрес — роль не назначена.
const char *DEFAULT_ADDR[SENSORS] = {
"280B65BE03AA7EAB",
"280F96A602D6392E",
"2844C32858F45713",
"28139EE317E80A85",
"2880AB7EC084E0A4",
"284C27E812B9C716",
"2826BB5233B7EDB8",
"286FC27D61D870B5",
"28572B9AB180D675",
"2870A72D6B48101D",
"282E6AC8E1A0102D",
};
// Состояния и аварии. Объявлены выше всех функций: Arduino вставляет
// прототипы перед первой из них, и типам в них надо уже существовать.
enum State : uint8_t { S_OFF, S_PUMPS, S_RUN, S_COOL, S_FAULT };
enum Fault : uint8_t { F_NONE, F_SENSOR, F_PSENSOR, F_LOW, F_HIGH, F_DISCH, F_FREEZE, F_FLOW_COLD, F_FLOW_HOT };
// --- фреоны -----------------------------------------------------------
// Давление насыщения, бар (абсолютное) × 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 по подаче, 3 по обратке
uint8_t freon; // номер в таблице
float target, hyst; // целевая температура и ширина гистерезиса, °C
float minLow, maxHigh; // пороги давления (избыточного), бар
float maxDisch; // предел температуры нагнетания и корпуса, °C
float minColdOut; // предел температуры воды на выходе испарителя, °C
float loRange, hiRange; // шкалы датчиков давления, бар
float divider; // делитель перед АЦП: напряжение датчика / напряжение на входе
uint16_t tBoot, tPumpBefore, tPumpAfter, tMinOff, tMinRun, tFlow, tRetry, tGrace, tEev;
uint8_t retries; // сколько раз плата сама пробует снова после аварии
float shTarget, kp, ki; // перегрев и коэффициенты ПИ
uint16_t eevMin, eevMax, eevStart;
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.minLow = 2.0; cfg.maxHigh = 35.0;
cfg.maxDisch = 110; cfg.minColdOut = 2.0;
cfg.loRange = 17.5; cfg.hiRange = 45.0;
cfg.divider = 1.5;
#if SIM_DEMO
cfg.tBoot = 8; cfg.tPumpBefore = 6; cfg.tPumpAfter = 6; cfg.tMinOff = 15;
cfg.tMinRun = 12; cfg.tFlow = 8; cfg.tRetry = 30; cfg.tGrace = 15; cfg.tEev = 12;
#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;
#endif
cfg.retries = 3;
cfg.shTarget = 5; cfg.kp = 4; cfg.ki = 0.6;
cfg.eevMin = 66; cfg.eevMax = 480; cfg.eevStart = 220;
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 }; // компрессор, скважина, пол, авария
float temp[SENSORS]; // NAN — датчика нет или не отвечает
byte fails[SENSORS];
float pLow = NAN, pHigh = NAN; // избыточное давление, бар
float tEvap = NAN, tCond = NAN, superheat = NAN, subcool = NAN;
bool flowCold = false, flowHot = 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]), ow2(PIN_OW[2]);
DallasTemperature dt0(&ow0), dt1(&ow1), dt2(&ow2);
DallasTemperature *dt[3] = { &dt0, &dt1, &dt2 };
const byte MAX_FOUND = 6;
DeviceAddress found[3][MAX_FOUND];
float foundTemp[3][MAX_FOUND];
byte foundCount[3];
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 < 3; 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 < 3; 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 < 3; l++)
for (byte i = 0; i < foundCount[l]; i++) {
float t = dt[l]->getTempC(found[l][i]);
foundTemp[l][i] = t <= -100 ? NAN : t;
}
}
// --- давление ---------------------------------------------------------
// Датчик 0,5–4,5 В на шкалу 0…range бар. Меньше 0,3 В — обрыв или
// замыкание на землю, а не «ноль бар».
float readGauge(byte pin, float range) {
long sum = 0;
for (byte i = 0; i < 8; i++) sum += analogRead(pin);
float v = sum / 8.0f / 4095.0f * 3.3f * cfg.divider;
if (v < 0.3f) return NAN;
float p = (v - 0.5f) / 4.0f * range;
return constrain(p, 0.0f, range);
}
void readPressure() {
static uint32_t t0 = 0;
if (millis() - t0 < 250) return;
t0 = millis();
float lo = readGauge(PIN_P_LOW, cfg.loRange), hi = readGauge(PIN_P_HIGH, cfg.hiRange);
// Сглаживание: одно значение шума не должно бросать ЭРВ.
pLow = isnan(lo) ? NAN : (isnan(pLow) ? lo : pLow + 0.4f * (lo - pLow));
pHigh = isnan(hi) ? NAN : (isnan(pHigh) ? hi : pHigh + 0.4f * (hi - pHigh));
tEvap = isnan(pLow) ? NAN : satTemp(cfg.freon, pLow + 1.013f);
tCond = isnan(pHigh) ? NAN : satTemp(cfg.freon, pHigh + 1.013f);
superheat = (isnan(tEvap) || isnan(temp[T_SUCTION])) ? NAN : temp[T_SUCTION] - tEvap;
subcool = (isnan(tCond) || isnan(temp[T_LIQUID])) ? NAN : tCond - temp[T_LIQUID];
}
// Протоки: контакт на землю, три одинаковых отсчёта подряд.
void readFlows() {
static uint32_t t0 = 0;
static byte cold = 0, hot = 0;
if (millis() - t0 < 100) return;
t0 = millis();
cold = digitalRead(PIN_FLOW_COLD) == LOW ? min(cold + 1, 5) : 0;
hot = digitalRead(PIN_FLOW_HOT) == LOW ? min(hot + 1, 5) : 0;
flowCold = cold >= 3;
flowHot = hot >= 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, hFreeze, hFlowC, hFlowH, hSens, hPSens;
bool tempsCritical() {
return !isnan(temp[T_SUCTION]) && !isnan(temp[T_DISCH]) && !isnan(temp[T_LIQUID]) &&
!isnan(temp[T_COLD_OUT]);
}
bool demand() {
if (!cfg.enabled) { heat = false; return false; }
if (cfg.mode == 0) { heat = true; return true; }
float v = cfg.mode == 1 ? temp[T_ROOM] : cfg.mode == 2 ? temp[T_HOT_OUT] : temp[T_HOT_IN];
if (isnan(v)) return heat; // датчика нет — держим прежнее решение, а о датчике скажет защита
if (v < cfg.target - cfg.hyst / 2) heat = true;
else if (v > cfg.target + cfg.hyst / 2) heat = false;
return heat;
}
bool recoverable(Fault f) {
return f == F_LOW || f == F_HIGH || f == F_FREEZE || f == F_FLOW_COLD || f == F_FLOW_HOT;
}
void enter(State s) { state = s; stateAt = millis(); }
void raise(Fault f) {
if (state == S_FAULT) return;
fault = f;
faultAt = millis();
setRelay(0, R_COMP, false);
stopAt = millis();
stoppedOnce = true;
eevClose();
enter(S_FAULT);
note(String("АВАРИЯ: ") + FAULT_NAME[f] +
(recoverable(f) && retryCount < cfg.retries ? String(" — повтор через ") + cfg.tRetry + " с" : String(" — нужен сброс")));
}
void clearFault(const char *who) {
if (state != S_FAULT) return;
fault = F_NONE;
retryCount = 0;
enter(S_OFF);
note(String("авария сброшена (") + who + ")");
}
void emergencyStop() {
cfg.enabled = false;
saveSettings();
if (state == S_RUN || state == S_PUMPS) {
setRelay(0, R_COMP, false);
stopAt = millis();
stoppedOnce = true;
eevClose();
enter(S_COOL);
}
note("аварийный стоп");
}
void control() {
uint32_t now = millis();
bool want = demand();
// Защиты, не зависящие от состояния: давление и датчики давления —
// пока стоит компрессор, они показывают давление в покое.
bool running = state == S_RUN;
if (state != S_FAULT) {
if (hPSens.step(cfg.enabled && (isnan(pLow) || isnan(pHigh)), 3000)) raise(F_PSENSOR);
else if (running && hHigh.step(pHigh > cfg.maxHigh, 1000)) raise(F_HIGH);
else if (running && hDisch.step(max(isnan(temp[T_DISCH]) ? 0 : temp[T_DISCH],
isnan(temp[T_COMP]) ? 0 : temp[T_COMP]) > cfg.maxDisch, 2000)) raise(F_DISCH);
}
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(1, R_COLD, true);
eev.enableOutputs();
eevTarget = cfg.eevStart;
eev.moveTo(cfg.eevStart);
enter(S_PUMPS);
note("запуск: насос скважины включён");
break;
case S_PUMPS: {
if (!relayOn[2] && now - stateAt >= 2000) {
setRelay(2, R_HOT, true);
note("насос тёплого пола включён");
}
uint32_t wait = cfg.tFlow * 1000UL;
if (!flowCold && now - stateAt >= wait) { raise(F_FLOW_COLD); break; }
if (relayOn[2] && !flowHot && now - stateAt >= 2000 + wait) { raise(F_FLOW_HOT); break; }
if (!want) { setRelay(1, R_COLD, false); setRelay(2, R_HOT, false); eevClose(); enter(S_OFF); note("запрос снят, насосы выключены"); break; }
if (flowCold && flowHot && relayOn[2] && now - stateAt >= cfg.tPumpBefore * 1000UL && eev.distanceToGo() == 0) {
setRelay(0, R_COMP, true);
enter(S_RUN);
note(String("компрессор включён, давление ") + fmt(pLow) + " / " + fmt(pHigh) + " бар");
}
break;
}
case S_RUN: {
bool grace = now - stateAt < cfg.tGrace * 1000UL;
if (hFlowC.step(!flowCold, 3000)) { raise(F_FLOW_COLD); break; }
if (hFlowH.step(!flowHot, 3000)) { raise(F_FLOW_HOT); break; }
if (!grace && hLow.step(pLow < cfg.minLow, 5000)) { raise(F_LOW); break; }
if (hFreeze.step(!isnan(temp[T_COLD_OUT]) && temp[T_COLD_OUT] < cfg.minColdOut, 5000)) { raise(F_FREEZE); break; }
if (hSens.step(!tempsCritical(), 10000)) { raise(F_SENSOR); break; }
if (now - stateAt > 600000UL && retryCount) retryCount = 0; // десять минут без аварий — счёт попыток заново
if (!want && now - stateAt >= cfg.tMinRun * 1000UL) {
setRelay(0, R_COMP, false);
stopAt = now;
stoppedOnce = true;
eevClose();
enter(S_COOL);
note("компрессор выключен");
}
break;
}
case S_COOL:
if (now - stateAt >= cfg.tPumpAfter * 1000UL) {
setRelay(1, R_COLD, false);
setRelay(2, R_HOT, false);
enter(S_OFF);
note("насосы выключены");
}
break;
case S_FAULT:
// Насосы после аварии по давлению и температуре ещё крутятся —
// унести тепло; при пропавшем протоке — сразу стоп, они и так сухие.
if (now - faultAt >= cfg.tPumpAfter * 1000UL || fault == F_FLOW_COLD || fault == F_FLOW_HOT) {
setRelay(1, R_COLD, false);
setRelay(2, R_HOT, false);
}
if (recoverable(fault) && retryCount < cfg.retries && now - faultAt >= cfg.tRetry * 1000UL) {
retryCount++;
fault = F_NONE;
enter(S_OFF);
note(String("повторный пуск, попытка ") + retryCount);
}
break;
}
setRelay(3, R_ALARM, state == S_FAULT);
}
// --- кнопка -----------------------------------------------------------
void handleButton() {
static bool down = false, longDone = false;
static uint32_t at = 0;
bool now = digitalRead(PIN_BUTTON) == LOW;
if (now && !down) { down = true; longDone = false; at = millis(); }
if (down && now && !longDone && millis() - at >= 1500) {
longDone = true;
clearFault("кнопка");
}
if (!now && down) {
down = false;
if (!longDone && millis() - at >= 40) {
cfg.enabled = !cfg.enabled;
saveSettings();
note(cfg.enabled ? "включено кнопкой" : "выключено кнопкой");
}
}
}
// --- страница: GyverLibs/Settings ---------------------------------------
SettingsESP sett("Тепловой насос");
enum : size_t {
W_STATE = 1, W_FAULT, W_LED_COMP, W_LED_COLD, W_LED_HOT, W_LED_ALARM, W_JOURNAL,
W_PLOW, W_PHIGH, W_TEVAP, W_TCOND, W_SH, W_SC, W_EEV, W_FLOWC, W_FLOWH,
W_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 (left > 0) t += String(", пуск через ") + left + " с";
else if (!eevHomed) t += ", ЭРВ ищет ноль";
else if (!heat) t += ", тепла не нужно";
} else if (state != S_OFF) t += String(", ") + s + " с";
return t;
}
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_COLD, "Насос скважины", relayOn[1], sets::Colors::Gray, sets::Colors::Green);
b.LED(W_LED_HOT, "Насос тёплого пола", relayOn[2], sets::Colors::Gray, sets::Colors::Green);
b.LED(W_LED_ALARM, "Реле аварии", relayOn[3], sets::Colors::Gray, sets::Colors::Red);
b.Label(W_FLOWC, "Проток скважины", flowCold ? "есть" : "нет");
b.Label(W_FLOWH, "Проток тёплого пола", flowHot ? "есть" : "нет");
b.endGroup();
}
if (b.beginGroup("Управление")) {
if (b.Switch("Установка включена", &cfg.enabled)) {
changed = true;
note(cfg.enabled ? "включено со страницы" : "выключено со страницы");
}
if (b.Select("Как решаем, греть ли", "Всегда (ручной);По помещению;По подаче пола;По обратке пола", &cfg.mode)) changed = true;
if (b.Number("Целевая температура, °C", &cfg.target, 5, 60)) changed = true;
if (b.Number("Гистерезис, °C", &cfg.hyst, 0.5, 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_SC, "Переохлаждение жидкости", fmt(subcool) + " К");
b.Label(W_EEV, "ЭРВ", eevText());
b.endGroup();
}
if (b.beginGroup("Защиты")) {
if (b.Number("Низкое давление, бар", &cfg.minLow, 0, 10)) changed = true;
if (b.Number("Высокое давление, бар", &cfg.maxHigh, 5, 60)) changed = true;
if (b.Number("Нагнетание не выше, °C", &cfg.maxDisch, 60, 140)) changed = true;
if (b.Number("Выход испарителя не ниже, °C", &cfg.minColdOut, -5, 10)) changed = true;
if (b.Spinner("Попыток после аварии", 0, 9, 1, &cfg.retries)) changed = true;
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.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 < 3; 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_COLD, relayOn[1]);
u.update(W_LED_HOT, relayOn[2]);
u.update(W_LED_ALARM, relayOn[3]);
u.update(W_FLOWC, flowCold ? "есть" : "нет");
u.update(W_FLOWH, flowHot ? "есть" : "нет");
for (byte i = 0; i < SENSORS; i++) u.update(W_T + i, tempText(i));
u.update(W_PLOW, fmt(pLow) + " бар");
u.update(W_PHIGH, fmt(pHigh) + " бар");
u.update(W_TEVAP, fmt(tEvap) + " °C");
u.update(W_TCOND, fmt(tCond) + " °C");
u.update(W_SH, fmt(superheat) + " К");
u.update(W_SC, fmt(subcool) + " К");
u.update(W_EEV, eevText());
for (byte l = 0; l < 3; 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 < 3; 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_COLD, R_HOT, R_ALARM };
for (byte r : relays) { pinMode(r, OUTPUT); digitalWrite(r, RELAY_OFF); }
pinMode(PIN_LED, OUTPUT);
pinMode(PIN_FLOW_COLD, INPUT_PULLUP);
pinMode(PIN_FLOW_HOT, INPUT_PULLUP);
pinMode(PIN_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();
readFlows();
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: 11 датчиков DS18B20, ЭРВ и панель Settings
Контроллер теплового насоса вода–вода на базе наружного блока кондиционера: 11 термометров DS18B20 на трёх линиях 1-Wire, два датчика давления фреона, ЭРВ на шаговом двигателе с ПИ-регулированием перегрева, четыре реле (компрессор, насосы, авария), защиты и веб-панель на GyverLibs/Settings. Таблицы для семи фреонов.
Что на схеме
Плата ESP32 DevKit v1, к ней подключены:
- DS18B20 (термометр 1-Wire) —
tf1, температура 8 °C - DS18B20 (термометр 1-Wire) —
tf2, температура 80 °C - DS18B20 (термометр 1-Wire) —
tf3, температура 30 °C - DS18B20 (термометр 1-Wire) —
tf4, температура 3 °C - DS18B20 (термометр 1-Wire) —
tc1, температура 12 °C - DS18B20 (термометр 1-Wire) —
tc2, температура 7 °C - DS18B20 (термометр 1-Wire) —
tc3, температура 9 °C - DS18B20 (термометр 1-Wire) —
th1, температура 30 °C - DS18B20 (термометр 1-Wire) —
th2, температура 34 °C - DS18B20 (термометр 1-Wire) —
th3, температура 22 °C - DS18B20 (термометр 1-Wire) —
th4, температура 45 °C - Шаговый двигатель —
eev - Потенциометр —
ph - Потенциометр —
pl - Переключатель —
fc - Переключатель —
fh - Кнопка —
btn - Реле —
k1 - Реле —
k2 - Реле —
k3 - Реле —
k4 - Ввод 230 В —
in - Автоматический выключатель —
qf1 - Автоматический выключатель —
qf2 - Контактор —
km1 - Двигатель однофазный —
mc - Двигатель однофазный —
mw - Двигатель однофазный —
mf - Светильник 230 В —
la1, тип: импульсный
Задействованы выводы платы: 3V3, GND, D4, D5, D23, D18, D19, D21, D22, D34, D35, D14, D27, D13, D25, D26, D32, D33. Всего соединений на схеме: 78.
Схема и что в ней написано
Установка вода–вода на базе наружного блока кондиционера «старт–стоп» (без инвертора): вода из скважины кипятит фреон в испарителе, компрессор его сжимает, в конденсаторе фреон отдаёт тепло воде тёплого пола, а электронный расширительный вентиль (ЭРВ) на шаговом двигателе дросселирует жидкость обратно к испарителю. Контроллер сам решает, когда пускать и останавливать, и следит за тем, чтобы установка не убила себя.
Датчики температуры: три линии, до пяти датчиков на одной
DS18B20 отвечают по одному проводу, и на один вывод платы их вешают параллельно: у каждого свой 64-битный адрес. Здесь три линии:
- D4 — фреон: всасывание, нагнетание, жидкость после конденсатора, вход испарителя после ЭРВ;
- D5 — скважина: вход и выход испарителя, улица;
- D23 — тёплый пол: обратка и подача, помещение, корпус компрессора.
На каждую линию нужен один резистор 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»). Перед пуском компрессора вентиль открывается на стартовое положение (220 шагов), через tEev секунд после пуска включается скоростной ПИ-регулятор: каждые 5 секунд шаг = Kp·(ошибка − прошлая) + Ki·ошибка·5 с, ошибка = перегрев − цель. Границы хода (66–480 шагов), Kp, Ki и цель — в панели.
Подключение железа. На схеме двигатель ЭРВ подключён к плате напрямую — для симулятора этого хватает. Для настоящего ЭРВ нужен драйвер: ULN2003/ULN2803 для униполярного (5 проводов, 12 В), L298N или DRV8833 для биполярного. Порядок обмоток и число шагов на полный ход — из паспорта ЭРВ.
Пуск, работа, остановка
Плата держит пять состояний: стоп → запуск насосов → работа → выбег насосов → авария. Порядок пуска: включается насос скважины, через 2 секунды — насос тёплого пола, ждём проток по обоим контурам (не дождались — авария), выдерживаем tPumpBefore секунд, ЭРВ уже на стартовом положении — включаем компрессор. Стоп идёт обратным порядком: сначала компрессор (не раньше, чем через tMinRun с после пуска), ЭРВ закрывается, насосы работают ещё tPumpAfter секунд, чтобы унести тепло. Между двумя пусками компрессора выдерживается пауза tMinOff.
Как принимается решение «греть»: «всегда» (ручной режим), по температуре помещения, подачи или обратки тёплого пола, с гистерезисом.
Защиты
- низкое давление — ниже порога дольше 5 с (не проверяется первые
tGraceсекунд после пуска); - высокое давление — выше порога дольше 1 с;
- перегрев нагнетания — температура нагнетания или корпуса выше предела дольше 2 с;
- промерзание испарителя — вода на выходе из испарителя холоднее порога дольше 5 с;
- нет протока скважины или тёплого пола — при пуске и во время работы;
- пропал датчик температуры (всасывание, нагнетание, жидкость, выход скважины) или датчик давления.
Часть аварий устраняется сама: через tRetry секунд плата пробует снова, до трёх попыток; сбросом счётчика служат 10 минут работы без аварий. Нагнетание, датчики давления и температуры — остаются до ручного сброса (кнопка на панели, долгое нажатие кнопки на плате, команда reset в порт). Реле 4 включено, пока идёт авария: к нему подключают лампу, сирену или вход котла.
Контроллер — не единственная защита. Для настоящего компрессора поставьте реле высокого и низкого давления, включённые последовательно в цепь катушки контактора: они отключат компрессор, даже если плата зависла или пропало питание датчика.
Панель Settings
«Сейчас» — состояние, авария, светодиоды реле и протоки. «Управление» — включение, режим, цель, гистерезис, фреон, сброс аварии и аварийный стоп. «Температуры» — все 11 датчиков. «Фреон и ЭРВ» — давления, температуры кипения и конденсации, перегрев, переохлаждение, положение вентиля. Дальше — защиты, задержки, ЭРВ, датчики давления, адреса датчиков и журнал. Настройки хранятся в NVS и переживают выключение.
Что заменить при сборке
- поменять
SIM_DEMOна 0 — вернутся реальные задержки (пауза 5 минут, выбег 1 минуту); - поставить драйвер ЭРВ и датчики давления с делителем;
- реле — модуль AC-DC-ESP32-Relay-x4 с выводами 25, 26, 32, 33; если реле включаются низким уровнем, поменять
RELAY_ON; - контактор компрессора и его защиту по мощности подобрать по паспорту блока; в симуляторе это контактор на катушку 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()— поднимает собственную точку доступа
В скетче 884 строки; свои функции: satTemp(), setDefaults(), saveSettings(), loadSettings(), note(), setRelay(), addrText(), parseAddr(); подключено заголовков: 7. Сам скетч открыт на соседней вкладке — его можно менять и запускать заново.
Что попробовать изменить
- Нажмите «Запустить». Плата найдёт датчики на трёх линиях (4, 3 и 4 — в мониторе порта), найдёт ноль ЭРВ и поднимет страницу. Сценарий сам переведёт ползунки протока fc и fh вправо — «течёт»; без сценария сделайте это мышью, иначе установка не запустится.
- Нажмите кнопку на схеме: установка включится. Насос скважины, через 2 секунды насос тёплого пола, затем компрессор — на схеме сработают реле и контактор, в панели загорятся светодиоды.
- Откройте «Веб-интерфейс»: температуры всех одиннадцати датчиков, давления, кипение и конденсация по таблице R410A, перегрев и положение ЭРВ. Смените фреон на R22 — температуры кипения пересчитаются.
- Нажмите на деталь tf1 (всасывание) и поднимите температуру: перегрев вырастет, и через несколько секунд ЭРВ откроется — вращение видно на схеме и в журнале.
- Переведите ползунок fc (проток скважины) влево: через 3 секунды компрессор остановится, реле аварии замкнётся, в панели — «нет протока скважины». Верните ползунок и долгим нажатием кнопки сбросьте аварию.
- Крутите ручку ph выше 80 % — сработает защита по высокому давлению; ph и pl ниже 5 % — обрыв датчика.
Смотрите также
- Подключение Шаговый двигатель — Биполярный шаговый двигатель
- Подключение Потенциометр — Переменный резистор
- Подключение Кнопка — Тактовая кнопка
- Подключение Модуль реле — Реле для управления нагрузкой
- Автополив и фитолампа на ESP32 с веб-админкой — То же устройство, что на Arduino Nano — фитолампа 230 В по расписанию и два насоса 12 В со своим периодом полива, — но с…
- Автополив и фитолампа на ESP32: веб-админка и MQTT — Третий вариант прибора: к веб-админке ESP32 добавлен MQTT. Состояние — климат, лампа, насосы, всё расписание — уходит бр…
- Автополив и фитолампа на ESP32: панель на GyverLibs/Settings — Четвёртый вариант прибора: веб-админка не написана руками, а собрана библиотекой GyverLibs/Settings — скетч только переч…
- Тепловой насос воздух–вода на ESP32: оттайка, ЭРВ и панель Settings — Контроллер теплового насоса воздух–вода на базе наружного блока кондиционера: шесть термометров DS18B20 на двух линиях 1…
- Сейф с кодом: клавиатура, сервозамок и OLED — Код набирается на клавиатуре, «#» — ввод, «*» — стереть. Верный код отводит ригель сервоприводом на пять секунд. Три оши…
- Светофор с кнопкой пешехода и обратным отсчётом — Машинам горит зелёный, пока никто не нажал кнопку. Нажали — жёлтый, красный, а пешеходам зелёный с отсчётом секунд на ин…
Сборка ещё не запускалась.
Печатайте в Serial числа — они станут графиком.
Serial.println(t); — один ряд,
Serial.printf("t:%.1f h:%.1f\n", t, h); — два с подписями.
Формат тот же, что понимает плоттер Arduino IDE.