В предыдущей статье https://habr.com/ru/articles/1016552/ я рассматривал реализацию снятия показаний счётчика электроэнергии МИР С-05.10–230-5(80)‑G2Z1B‑KNQ‑S-D по Bluetooth (в то время как официально API нигде не опубликован) с помощью Raspberry Pi.
Конечно использовать малинку для такой задачи это стрельба из пушки по воробьям - поэтому в продолжении темы я решил перейти на ESP32. Так как рядом со счётчиком у меня находится Ethernet коммутатор, то я решил обойтись без Wi-Fi и для этих целей приобрёл ESP32 ETH01 с Ethernet-портом.

К счастью у него оказался вагон встроенной памяти в размере 8 мегабайт, поэтому для моей задачи вполне хватит. И можно даже ещё нагрузить...

Однако при адаптации кода от малинки к Arduino IDE возникли проблемы. МИР не отдавал все значения одним коротким бинарным кадром. Обмен представлял собой многошаговую последовательность с подключением, авторизацией и чтением “экранных страниц”.
Рабочий механизм BLE включал:
Подключение к MAC счётчика.
Запись 0x01 в характеристику B3F7.
Передачу PIN-кода в D24A в little-endian формате.
Подписку на notify характеристики FEC2.
Отправку команд чтения.
Обработку notify-ответов в CP1251/текстовом виде.
Первые реализации опрашивали МИР фиксированной последовательностью команд: “энергия”, несколько раз “следующая страница”, затем “параметры”. Но лог показал, что счётчик ведёт себя как меню с плавающей текущей позицией. В одном цикле после команды энергии могли прийти (t1 - дневной тариф, t2 - ночной тариф):
total → T2 → T1
в другом:
total → T1 → total → T1
в третьем:
total → T1 → total → T2
Из-за этого фиксированное количество команд next иногда пропускало T1 или T2. Парсер был не виноват: когда строка реально содержала прям.т.1, он парсил T1; когда содержала прям.т.2, он парсил T2. Проблема была именно в навигации по страницам МИР. В логе было видно, что один цикл мог завершиться с t2=null, хотя total и T1 были получены.
Решение было изменить логику с “фиксированной последовательности” на “сканирование до результата”:
Отправить команду входа в раздел энергии.
Читать текущую страницу.
Отправлять NEXT до тех пор, пока не будут найдены total, T1 и T2.
Ограничить количество шагов, чтобы не зависнуть.
После этого перейти к текущим параметрам и аналогично найти дату, время, ток и напряжение.
В итоговом коде МИР-опрос стал результатно-ориентированным: я больше не надееюсь, что T1 и T2 окажутся на строго заданных шагах. Вместо этого код смотрит содержимое полученной строки и выставляет флаги:
poll_total_found
poll_t1_found
poll_t2_found
Если все три флага стали true, энергетический цикл завершается досрочно. Если какой-то тариф не пришёл в конкретном цикле, старое успешное значение не сбрасывается в null; дополнительно хранятся времена последнего успешного обновления t1_last_ok_ms, t2_last_ok_ms.
Также в прошивку для ESP32 я внедрил подключение по локальной сети со статическим IP-адресом 192.168.1.60 и отображение JSON полученных параметров со счётчика по адресу http:/192.168.1.60/api с помощью REST API. И также в прошивку внедрил OTA (обновление прошивки по сети), чтобы не заморачиваться больше с USB-TTL адаптером для прошивки.
В результате вывод показаний по адресу http:/192.168.1.60/api стал выглядеть следующим образом:
{ “device”: “esp32_eth01_mir_ble”, “eth_connected”: true, “ip”: “192.168.1.60”, “ota_started”: true, “uptime_ms”: 6508849, “auto”: { “mir_interval_ms”: 180000, “next_mir_due_ms”: 6504638 }, “mir”: { “device”: “mir_ble”, “meter_mac”: “E4:06:BF:87:CD:69”, “pin_used”: 58525, “last_read_ok”: false, “last_error”: “connected”, “last_poll_ms”: 6504638, “last_ok_ms”: 6324626, “notify_count”: 2, “poll_total_found”: true, “poll_t1_found”: false, “poll_t2_found”: false, “total_kwh”: 624.01, “t1_kwh”: 467.85, “t2_kwh”: 156.12, “total_last_ok_ms”: 6508714, “t1_last_ok_ms”: 6314342, “t2_last_ok_ms”: 6309864, “date”: “07.05.26”, “time”: “22:48:01”, “current_a”: 2.63, “voltage_v”: 231.45, “current_last_ok_ms”: 0, “voltage_last_ok_ms”: 0, “last_text”: “? / Актив.эн. прям. я 624.01 кВт*ч ч=” } }
Вот какой код получился в итоге только для получения показаний МИР по BLE и публикация их в REST API с помощью ESP32:
Скрытый текст
#include <Arduino.h> /* ESP32-ETH01 / WT32-ETH01 Ethernet-настройки. ВАЖНО: Эти define должны быть ДО #include <ETH.h> */ #define ETH_PHY_TYPE ETH_PHY_LAN8720 #define ETH_PHY_ADDR 1 #define ETH_PHY_MDC 23 #define ETH_PHY_MDIO 18 #define ETH_PHY_POWER 16 #define ETH_CLK_MODE ETH_CLOCK_GPIO0_IN #include <ETH.h> #include <WebServer.h> #include <ArduinoOTA.h> #include <NimBLEDevice.h> #include <math.h> #include <ctype.h> /* ============================================================ Ethernet / HTTP / OTA ============================================================ */ WebServer server(80); IPAddress localIP(192, 168, 1, 60); IPAddress gateway(192, 168, 1, 1); IPAddress subnet(255, 255, 255, 0); IPAddress dns1(192, 168, 1, 1); bool ethConnected = false; bool otaStarted = false; bool insideHttpHandler = false; /* Автоопрос МИР. 180000 мс = 3 минуты. */ const unsigned long mirPollInterval = 180000; unsigned long nextMirPollMs = 0; /* ============================================================ МИР BLE ============================================================ */ static NimBLEAddress mirMeterAddr(std::string("E4:06:BF:87:CD:69"), BLE_ADDR_PUBLIC); /* PIN счётчика МИР. */ uint32_t MIR_PIN_CODE = 58525; /* UUID из рабочего BLE-механизма МИР. */ static const char* SVC_5336 = "53367898-fdd5-46cc-81e6-b79a008ce1ad"; static const char* SVC_4880 = "4880c12c-fdcb-4077-8920-a450d7f9b907"; static const char* UUID_D24A = "d24a5138-1448-48ea-a983-f7df274c6d89"; static const char* UUID_B3F7 = "b3f7e595-2951-42fa-879e-0d9dfa5e846e"; static const char* UUID_FEC2 = "fec26ec4-6d71-4442-9f81-55bc21d658d6"; static NimBLEClient* mirClient = nullptr; static NimBLERemoteCharacteristic* ch_d24a = nullptr; static NimBLERemoteCharacteristic* ch_b3f7 = nullptr; static NimBLERemoteCharacteristic* ch_fec2 = nullptr; bool mirLastReadOk = false; String mirLastError = "not polled yet"; unsigned long mirLastPollMs = 0; unsigned long mirLastOkMs = 0; String mirLastText = ""; uint32_t mirNotifyCount = 0; /* Флаги текущего цикла опроса. */ bool mirThisPollTotal = false; bool mirThisPollT1 = false; bool mirThisPollT2 = false; bool mirThisPollDate = false; bool mirThisPollTime = false; bool mirThisPollCurrent = false; bool mirThisPollVoltage = false; /* Последние успешные значения. Важно: если в одном цикле T1/T2 не пришли, старые значения не затираются в null. */ struct MirData { bool total_valid = false; bool t1_valid = false; bool t2_valid = false; bool date_valid = false; bool time_valid = false; bool current_valid = false; bool voltage_valid = false; float total_kwh = 0.0f; float t1_kwh = 0.0f; float t2_kwh = 0.0f; float current_a = 0.0f; float voltage_v = 0.0f; unsigned long total_last_ok_ms = 0; unsigned long t1_last_ok_ms = 0; unsigned long t2_last_ok_ms = 0; unsigned long current_last_ok_ms = 0; unsigned long voltage_last_ok_ms = 0; String date = ""; String time = ""; }; MirData mirData; /* ============================================================ Лёгкий лог ============================================================ */ #define LOG_LINES 80 String logBuffer[LOG_LINES]; int logIndex = 0; bool logWrapped = false; void addLog(String msg) { String line = String(millis()) + " ms | " + msg; logBuffer[logIndex] = line; logIndex++; if (logIndex >= LOG_LINES) { logIndex = 0; logWrapped = true; } Serial.println(line); } String makeLogText() { String out; out += "ESP32 ETH01 MIR BLE log\n"; out += "uptime_ms="; out += String(millis()); out += "\n\n"; int start = logWrapped ? logIndex : 0; int count = logWrapped ? LOG_LINES : logIndex; for (int i = 0; i < count; i++) { int idx = (start + i) % LOG_LINES; out += logBuffer[idx]; out += "\n"; } return out; } /* ============================================================ Общие функции ============================================================ */ String jsonEscape(const String& s) { String out = ""; for (size_t i = 0; i < s.length(); i++) { char c = s[i]; if (c == '\\') out += "\\\\"; else if (c == '"') out += "\\\""; else if (c == '\n') out += "\\n"; else if (c == '\r') out += "\\r"; else if (c == '\t') out += "\\t"; else if ((uint8_t)c < 32) out += " "; else out += c; } return out; } String bytesToHex(const uint8_t *data, int len) { String s; for (int i = 0; i < len; i++) { if (data[i] < 0x10) s += "0"; s += String(data[i], HEX); if (i < len - 1) s += " "; } s.toUpperCase(); return s; } /* Во время длинного BLE-опроса обслуживаем OTA и HTTP. */ void serviceBackground(unsigned long ms) { unsigned long start = millis(); while (millis() - start < ms) { if (otaStarted) { ArduinoOTA.handle(); } if (!insideHttpHandler) { server.handleClient(); } delay(5); } } /* ============================================================ МИР: обработка текста ============================================================ */ void resetMirThisPollFlags() { mirThisPollTotal = false; mirThisPollT1 = false; mirThisPollT2 = false; mirThisPollDate = false; mirThisPollTime = false; mirThisPollCurrent = false; mirThisPollVoltage = false; } /* Ответы МИР приходят текстом в CP1251. */ std::string cp1251ToUtf8(const std::string& in) { String out = ""; for (uint8_t c : in) { if (c == 0x00) { out += ' '; } else if (c < 0x80) { out += (char)c; } else if (c == 0xA8) { out += "\xD0\x81"; } else if (c == 0xB8) { out += "\xD1\x91"; } else if (c >= 0xC0 && c <= 0xFF) { uint16_t unicode = 0x0410 + (c - 0xC0); out += char(0xD0 + (unicode > 0x043F ? 1 : 0)); if (unicode <= 0x043F) { out += char(0x80 + (unicode - 0x0400)); } else { out += char(0x80 + (unicode - 0x0440)); } } else { out += '?'; } } return std::string(out.c_str()); } String normalizeText(const String& input) { String out = ""; for (size_t i = 0; i < input.length(); i++) { char c = input[i]; if ((uint8_t)c >= 32 || c == '\n' || c == '\r' || c == '\t') { out += c; } else { out += ' '; } } String compact = ""; bool prevSpace = false; for (size_t i = 0; i < out.length(); i++) { char c = out[i]; bool isSpace = (c == ' ' || c == '\t' || c == '\r' || c == '\n'); if (isSpace) { if (!prevSpace) compact += ' '; prevSpace = true; } else { compact += c; prevSpace = false; } } compact.trim(); return compact; } bool mirTextIsEnergy(const String& text) { return text.indexOf("Актив.эн") >= 0; } bool mirTextIsT1(const String& text) { return text.indexOf("т.1") >= 0 || text.indexOf("т1") >= 0; } bool mirTextIsT2(const String& text) { return text.indexOf("т.2") >= 0 || text.indexOf("т2") >= 0; } float extractLastFloat(const String& text) { float found = NAN; int i = 0; while (i < (int)text.length()) { while (i < (int)text.length() && !isdigit(text[i])) i++; if (i >= (int)text.length()) break; int start = i; bool dotSeen = false; while (i < (int)text.length()) { char c = text[i]; if (isdigit(c)) { i++; continue; } if (c == '.' && !dotSeen) { dotSeen = true; i++; continue; } break; } String token = text.substring(start, i); if (token.indexOf('.') >= 0) { float v = token.toFloat(); if (v > 0.0f) { found = v; } } } return found; } String extractDate(const String& text) { for (size_t i = 0; i + 7 < text.length(); i++) { if (isdigit(text[i]) && isdigit(text[i + 1]) && text[i + 2] == '.' && isdigit(text[i + 3]) && isdigit(text[i + 4]) && text[i + 5] == '.' && isdigit(text[i + 6]) && isdigit(text[i + 7])) { return text.substring(i, i + 8); } } return ""; } String extractTime(const String& text) { for (size_t i = 0; i + 4 < text.length(); i++) { if (i + 7 < text.length() && isdigit(text[i]) && isdigit(text[i + 1]) && text[i + 2] == ':' && isdigit(text[i + 3]) && isdigit(text[i + 4]) && text[i + 5] == ':' && isdigit(text[i + 6]) && isdigit(text[i + 7])) { return text.substring(i, i + 8); } if (isdigit(text[i]) && isdigit(text[i + 1]) && text[i + 2] == ':' && isdigit(text[i + 3]) && isdigit(text[i + 4])) { return text.substring(i, i + 5); } } return ""; } void parseMirText(const String& text) { if (mirTextIsEnergy(text) && mirTextIsT1(text)) { float v = extractLastFloat(text); if (!isnan(v)) { mirData.t1_kwh = v; mirData.t1_valid = true; mirData.t1_last_ok_ms = millis(); mirThisPollT1 = true; addLog(String("MIR PARSE T1=") + String(v, 2)); } return; } if (mirTextIsEnergy(text) && mirTextIsT2(text)) { float v = extractLastFloat(text); if (!isnan(v)) { mirData.t2_kwh = v; mirData.t2_valid = true; mirData.t2_last_ok_ms = millis(); mirThisPollT2 = true; addLog(String("MIR PARSE T2=") + String(v, 2)); } return; } if (mirTextIsEnergy(text) && text.indexOf("прям") >= 0 && !mirTextIsT1(text) && !mirTextIsT2(text)) { float v = extractLastFloat(text); if (!isnan(v)) { mirData.total_kwh = v; mirData.total_valid = true; mirData.total_last_ok_ms = millis(); mirThisPollTotal = true; addLog(String("MIR PARSE TOTAL=") + String(v, 2)); } return; } if (text.indexOf("ДАТА") >= 0) { String d = extractDate(text); if (d.length() > 0) { mirData.date = d; mirData.date_valid = true; mirThisPollDate = true; addLog(String("MIR PARSE DATE=") + d); } return; } if (text.indexOf("ВРЕМЯ") >= 0) { String t = extractTime(text); if (t.length() > 0) { mirData.time = t; mirData.time_valid = true; mirThisPollTime = true; addLog(String("MIR PARSE TIME=") + t); } return; } if (text.indexOf("ТОК ФАЗЫ") >= 0) { float v = extractLastFloat(text); if (!isnan(v)) { mirData.current_a = v; mirData.current_valid = true; mirData.current_last_ok_ms = millis(); mirThisPollCurrent = true; addLog(String("MIR PARSE CURRENT=") + String(v, 2)); } return; } if (text.indexOf("НАПРЯЖЕНИЕ") >= 0 && text.indexOf("ФАЗЫ") >= 0) { float v = extractLastFloat(text); if (!isnan(v)) { mirData.voltage_v = v; mirData.voltage_valid = true; mirData.voltage_last_ok_ms = millis(); mirThisPollVoltage = true; addLog(String("MIR PARSE VOLTAGE=") + String(v, 2)); } return; } } /* Notify callback BLE. */ void mirNotifyCB( NimBLERemoteCharacteristic* pRemoteCharacteristic, uint8_t* pData, size_t length, bool isNotify) { mirNotifyCount++; std::string raw((char*)pData, length); std::string utf8 = cp1251ToUtf8(raw); String text = normalizeText(String(utf8.c_str())); mirLastText = text; /* Лог короткий, без HEX, чтобы /log не разрастался. */ addLog(String("MIR RX #") + String(mirNotifyCount) + " TXT " + text); parseMirText(text); } /* ============================================================ МИР: BLE-команды и опрос ============================================================ */ void buildAuthPayload(uint32_t pin, uint8_t out[4]) { out[0] = pin & 0xFF; out[1] = (pin >> 8) & 0xFF; out[2] = 0x00; out[3] = 0x00; } bool mirSendFec2Command(const uint8_t* cmd, size_t len, uint32_t waitMs) { if (!ch_fec2) { mirLastError = "fec2 characteristic missing"; return false; } bool ok = ch_fec2->writeValue(cmd, len, false); if (!ok) { mirLastError = "write fec2 failed"; addLog("MIR error: write fec2 failed"); return false; } serviceBackground(waitMs); return true; } void mirDisconnectClient() { if (mirClient) { if (mirClient->isConnected()) { mirClient->disconnect(); } NimBLEDevice::deleteClient(mirClient); mirClient = nullptr; } ch_d24a = nullptr; ch_b3f7 = nullptr; ch_fec2 = nullptr; } bool mirConnectAndSetup() { addLog("MIR connect start"); mirClient = NimBLEDevice::createClient(); if (!mirClient->connect(mirMeterAddr)) { mirLastError = "connect failed"; addLog(String("MIR error: ") + mirLastError); return false; } addLog("MIR connected"); NimBLERemoteService* svc5336 = mirClient->getService(SVC_5336); NimBLERemoteService* svc4880 = mirClient->getService(SVC_4880); if (!svc5336 || !svc4880) { mirLastError = "service not found"; addLog(String("MIR error: ") + mirLastError); mirDisconnectClient(); return false; } ch_d24a = svc5336->getCharacteristic(UUID_D24A); ch_b3f7 = svc4880->getCharacteristic(UUID_B3F7); ch_fec2 = svc4880->getCharacteristic(UUID_FEC2); if (!ch_d24a || !ch_b3f7 || !ch_fec2) { mirLastError = "characteristic not found"; addLog(String("MIR error: ") + mirLastError); mirDisconnectClient(); return false; } /* Включение обмена. */ uint8_t one = 0x01; if (!ch_b3f7->writeValue(&one, 1, true)) { mirLastError = "write b3f7 failed"; addLog(String("MIR error: ") + mirLastError); mirDisconnectClient(); return false; } /* Авторизация PIN-кодом. */ uint8_t auth[4]; buildAuthPayload(MIR_PIN_CODE, auth); if (!ch_d24a->writeValue(auth, 4, true)) { mirLastError = "write d24a failed"; addLog(String("MIR error: ") + mirLastError); mirDisconnectClient(); return false; } /* Подписка на ответы. */ if (!ch_fec2->canNotify()) { mirLastError = "fec2 notify unsupported"; addLog(String("MIR error: ") + mirLastError); mirDisconnectClient(); return false; } if (!ch_fec2->subscribe(true, mirNotifyCB)) { mirLastError = "subscribe failed"; addLog(String("MIR error: ") + mirLastError); mirDisconnectClient(); return false; } serviceBackground(500); mirLastError = "connected"; addLog("MIR auth and notify ok"); return true; } /* Чтение МИР: - сначала энергия; - крутим NEXT до TOTAL + T1 + T2; - потом текущие параметры. */ void mirReadMeterData() { static const uint8_t cmd_time[] = {0x00, 0x01, 0xFD, 0xC1, 0x1F}; static const uint8_t cmd_energy[] = {0x00, 0x01, 0xEE, 0xE3, 0x4D}; static const uint8_t cmd_next[] = {0x00, 0x01, 0x08, 0x7E, 0xA5}; static const uint8_t cmd_params[] = {0x00, 0x01, 0x02, 0xDF, 0xEF}; /* Время/дата часто помогают привести меню в понятное состояние. */ mirSendFec2Command(cmd_time, sizeof(cmd_time), 1500); /* Энергия: ищем total, T1, T2. */ addLog("MIR energy scan start"); mirSendFec2Command(cmd_energy, sizeof(cmd_energy), 1500); for (int i = 0; i < 16; i++) { if (mirThisPollTotal && mirThisPollT1 && mirThisPollT2) { addLog(String("MIR energy scan complete at step ") + String(i)); break; } mirSendFec2Command(cmd_next, sizeof(cmd_next), 1500); } if (!(mirThisPollTotal && mirThisPollT1 && mirThisPollT2)) { addLog(String("MIR energy partial total=") + String(mirThisPollTotal ? "1" : "0") + " t1=" + String(mirThisPollT1 ? "1" : "0") + " t2=" + String(mirThisPollT2 ? "1" : "0")); } /* Текущие параметры: дата, время, ток, напряжение. */ addLog("MIR params scan start"); mirSendFec2Command(cmd_params, sizeof(cmd_params), 1500); for (int i = 0; i < 8; i++) { if (mirThisPollDate && mirThisPollTime && mirThisPollCurrent && mirThisPollVoltage) { addLog(String("MIR params scan complete at step ") + String(i)); break; } mirSendFec2Command(cmd_next, sizeof(cmd_next), 1500); } if (!(mirThisPollDate && mirThisPollTime && mirThisPollCurrent && mirThisPollVoltage)) { addLog(String("MIR params partial date=") + String(mirThisPollDate ? "1" : "0") + " time=" + String(mirThisPollTime ? "1" : "0") + " current=" + String(mirThisPollCurrent ? "1" : "0") + " voltage=" + String(mirThisPollVoltage ? "1" : "0")); } } bool pollMirMeter() { mirLastPollMs = millis(); mirLastReadOk = false; mirLastError = "reading"; addLog("MIR poll start"); mirLastText = ""; mirNotifyCount = 0; resetMirThisPollFlags(); mirDisconnectClient(); if (!mirConnectAndSetup()) { mirDisconnectClient(); mirLastReadOk = false; return false; } mirReadMeterData(); serviceBackground(1500); mirDisconnectClient(); bool energyOk = mirThisPollTotal && mirThisPollT1 && mirThisPollT2; mirLastReadOk = energyOk; mirLastOkMs = millis(); if (energyOk) { mirLastError = "ok"; } else { mirLastError = "partial energy"; } addLog(String("MIR done status=") + mirLastError); addLog(String("MIR this poll total=") + String(mirThisPollTotal ? "1" : "0") + " t1=" + String(mirThisPollT1 ? "1" : "0") + " t2=" + String(mirThisPollT2 ? "1" : "0")); addLog(String("MIR saved total=") + (mirData.total_valid ? String(mirData.total_kwh, 2) : String("null"))); addLog(String("MIR saved t1=") + (mirData.t1_valid ? String(mirData.t1_kwh, 2) : String("null"))); addLog(String("MIR saved t2=") + (mirData.t2_valid ? String(mirData.t2_kwh, 2) : String("null"))); return energyOk; } /* ============================================================ JSON API ============================================================ */ String makeJson() { String json = "{"; json += "\"device\":\"esp32_eth01_mir_ble\","; json += "\"eth_connected\":"; json += ethConnected ? "true" : "false"; json += ","; json += "\"ip\":\""; json += ETH.localIP().toString(); json += "\","; json += "\"ota_started\":"; json += otaStarted ? "true" : "false"; json += ","; json += "\"uptime_ms\":"; json += String(millis()); json += ","; json += "\"auto\":{"; json += "\"mir_interval_ms\":"; json += String(mirPollInterval); json += ","; json += "\"next_mir_due_ms\":"; json += String(nextMirPollMs); json += "},"; json += "\"mir\":{"; json += "\"device\":\"mir_ble\","; json += "\"meter_mac\":\"E4:06:BF:87:CD:69\","; json += "\"pin_used\":"; json += String(MIR_PIN_CODE); json += ","; json += "\"last_read_ok\":"; json += mirLastReadOk ? "true" : "false"; json += ","; json += "\"last_error\":\""; json += jsonEscape(mirLastError); json += "\","; json += "\"last_poll_ms\":"; json += String(mirLastPollMs); json += ","; json += "\"last_ok_ms\":"; json += String(mirLastOkMs); json += ","; json += "\"notify_count\":"; json += String(mirNotifyCount); json += ","; json += "\"poll_total_found\":"; json += mirThisPollTotal ? "true" : "false"; json += ","; json += "\"poll_t1_found\":"; json += mirThisPollT1 ? "true" : "false"; json += ","; json += "\"poll_t2_found\":"; json += mirThisPollT2 ? "true" : "false"; json += ","; json += "\"total_kwh\":"; json += mirData.total_valid ? String(mirData.total_kwh, 2) : "null"; json += ","; json += "\"t1_kwh\":"; json += mirData.t1_valid ? String(mirData.t1_kwh, 2) : "null"; json += ","; json += "\"t2_kwh\":"; json += mirData.t2_valid ? String(mirData.t2_kwh, 2) : "null"; json += ","; json += "\"total_last_ok_ms\":"; json += String(mirData.total_last_ok_ms); json += ","; json += "\"t1_last_ok_ms\":"; json += String(mirData.t1_last_ok_ms); json += ","; json += "\"t2_last_ok_ms\":"; json += String(mirData.t2_last_ok_ms); json += ","; json += "\"date\":"; if (mirData.date_valid) { json += "\""; json += jsonEscape(mirData.date); json += "\""; } else { json += "null"; } json += ","; json += "\"time\":"; if (mirData.time_valid) { json += "\""; json += jsonEscape(mirData.time); json += "\""; } else { json += "null"; } json += ","; json += "\"current_a\":"; json += mirData.current_valid ? String(mirData.current_a, 2) : "null"; json += ","; json += "\"voltage_v\":"; json += mirData.voltage_valid ? String(mirData.voltage_v, 2) : "null"; json += ","; json += "\"current_last_ok_ms\":"; json += String(mirData.current_last_ok_ms); json += ","; json += "\"voltage_last_ok_ms\":"; json += String(mirData.voltage_last_ok_ms); json += ","; json += "\"last_text\":\""; json += jsonEscape(mirLastText); json += "\""; json += "}"; json += "}"; return json; } /* ============================================================ HTTP handlers ============================================================ */ void handleApi() { server.send(200, "application/json; charset=utf-8", makeJson()); } void handleLog() { server.send(200, "text/plain; charset=utf-8", makeLogText()); } void handleRoot() { String html; html += "<!doctype html><html><head><meta charset='utf-8'>"; html += "<meta http-equiv='refresh' content='10'>"; html += "<title>ESP32 ETH01 MIR BLE</title>"; html += "</head><body>"; html += "<h2>ESP32 ETH01 MIR BLE</h2>"; html += "<pre>"; html += makeJson(); html += "</pre>"; html += "<p>"; html += "<a href='/api'>/api</a> | "; html += "<a href='/json'>/json</a> | "; html += "<a href='/poll'>/poll MIR</a> | "; html += "<a href='/poll_mir'>/poll_mir MIR</a> | "; html += "<a href='/log'>/log</a>"; html += "</p>"; html += "</body></html>"; server.send(200, "text/html; charset=utf-8", html); } void handlePollMir() { insideHttpHandler = true; pollMirMeter(); insideHttpHandler = false; nextMirPollMs = millis() + mirPollInterval; server.send(200, "application/json; charset=utf-8", makeJson()); } /* ============================================================ OTA ============================================================ */ void startOTA() { if (otaStarted) return; ArduinoOTA.setHostname("mir-esp32"); ArduinoOTA.setPort(3232); ArduinoOTA.setPassword("12345678"); ArduinoOTA.onStart([]() { addLog("OTA start"); Serial.println("OTA start"); }); ArduinoOTA.onEnd([]() { addLog("OTA end"); Serial.println("OTA end"); }); ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) { Serial.printf("OTA progress: %u%%\r", (progress * 100) / total); }); ArduinoOTA.onError([](ota_error_t error) { addLog(String("OTA error code=") + String((int)error)); Serial.print("OTA error: "); Serial.println((int)error); }); ArduinoOTA.begin(); otaStarted = true; Serial.println("ArduinoOTA started on UDP port 3232"); addLog("ArduinoOTA started on UDP port 3232"); } /* ============================================================ Ethernet events ============================================================ */ void onEvent(arduino_event_id_t event) { switch (event) { case ARDUINO_EVENT_ETH_START: Serial.println("ETH Started"); ETH.setHostname("mir-esp32"); addLog("ETH Started"); break; case ARDUINO_EVENT_ETH_CONNECTED: Serial.println("ETH Connected"); addLog("ETH Connected"); break; case ARDUINO_EVENT_ETH_GOT_IP: Serial.print("ETH IP: "); Serial.println(ETH.localIP()); ethConnected = true; addLog(String("ETH GOT IP ") + ETH.localIP().toString()); startOTA(); break; case ARDUINO_EVENT_ETH_DISCONNECTED: Serial.println("ETH Disconnected"); ethConnected = false; addLog("ETH Disconnected"); break; case ARDUINO_EVENT_ETH_STOP: Serial.println("ETH Stopped"); ethConnected = false; addLog("ETH Stopped"); break; default: break; } } /* ============================================================ Setup / Loop ============================================================ */ void setup() { Serial.begin(115200); delay(1000); Serial.println(); Serial.println("ESP32 ETH01 MIR BLE reader"); addLog("BOOT"); /* BLE. */ NimBLEDevice::init(""); NimBLEDevice::setPower(ESP_PWR_LVL_P9); /* Ethernet. */ Network.onEvent(onEvent); ETH.begin( ETH_PHY_TYPE, ETH_PHY_ADDR, ETH_PHY_MDC, ETH_PHY_MDIO, ETH_PHY_POWER, ETH_CLK_MODE ); if (!ETH.config(localIP, gateway, subnet, dns1)) { Serial.println("ETH static IP config failed"); addLog("ETH static IP config failed"); } /* HTTP routes. */ server.on("/", handleRoot); server.on("/api", handleApi); server.on("/json", handleApi); server.on("/poll", handlePollMir); server.on("/poll_mir", handlePollMir); server.on("/log", handleLog); server.begin(); Serial.println("HTTP server started"); Serial.println("Open: http://192.168.1.60/api"); addLog("HTTP server started"); /* Первый автоопрос МИР через 30 секунд после старта. */ nextMirPollMs = millis() + 30000; } void loop() { if (otaStarted) { ArduinoOTA.handle(); } server.handleClient(); unsigned long now = millis(); if ((long)(now - nextMirPollMs) >= 0) { pollMirMeter(); nextMirPollMs = millis() + mirPollInterval; } }
Но на этой задаче я не остановился. У меня ещё был интерес снимать удалённо показания со счётчика воды... Для этих целей я начал подбирать решение на рынке устройств и выяснил, что таких устройств в России кот наплакал. И самое понятное решение, какое смог найти - это счётчик воды Бетар СГВ-Э с интерфейсом RS-485

Но найти счётчик в интернете - это ещё пол дела... Его ещё надо купить. И тут тоже возникла проблема. Потому что на маркетплейсах такой счётчик не продают (видимо цена кусачая 2980 рублей), поэтому я обратился в официальный магазин Бетар в своём городе. Там мне сообщили, что позиция заказная и привезут мне её через три недели. Поэтому взяли 100% предоплату и отправили ждать... Кстати из любопытства я у них спросил, сколько они таких счётчиков продают. И мне ответили, что несколько штук в год.
Через три недели приехал мой счётчик и я вызвал сантехника управляющей компании для установки (так как счётчик пломбируется УК). Пожилой сантехник УК приехал по заявке и наотрез отказался устанавливать этот счётчик с мотивировкой "такие счётчики не для квартир, а только для коттеджей" :) . Пришлось проводить ликбез через инженера УК.
Так как ESP32 не имеет прямой связи с шиной RS-485, то для такой связи я приобрёл адаптер MAX3485.

Он отличается от MAX485 тем, что работает с логикой 3,3 вольта, а не 5 вольт. А это родное напряжение для ESP32 и при такой схеме не требуется возни с резисторами или логическим преобразователем уровня.
Схема подключения получается следующей:

Параллельно, ещё перед установкой, я запросил у производителя по электронной почте описание протокола RS-485 для "Бетар". Протокол оказался довольно простым.
Счётчики Бетар СХВЭ/СГВЭ по RS-485 используют простой байтовый протокол. Обмен идёт на скорости: 9600 baud, 8N1. Для получения основных данных отправляется 7-байтный запрос: CD AA AA AA AA 71 CS , где:
CD стартовый байт запроса
AA AA AA AA адрес счётчика
71 команда запроса основных данных
CS контрольная сумма
У моего счётчика заводской номер: 64049899. По протоколу сетевой адрес счётчика совпадает с заводским номером. Поэтому сначала переводим десятичное число 64049899 в HEX:
64049899 decimal = 0x03D152EB
Затем разбиваем это 32-битное число на 4 байта от старшего к младшему:
03 D1 52 EB
Это и есть адрес счётчика. Команда основных данных — 71, поэтому без контрольной суммы запрос выглядит так:
CD 03 D1 52 EB 71
Теперь считаем checksum:
03 + D1 + 52 + EB + 71 = 0x282
Берём младший байт: 0x82. И получаем полный запрос:
CD 03 D1 52 EB 71 82
Ответ на основной запрос имеет длину 19 байт и начинается со стартового байта: 5А. Дальше идут:
5A старт ответа
03 D1 52 EB адрес счётчика
4 байта прямой поток
4 байта обратный поток
4 байта время магнитного воздействия
1 байт служебный байт
1 байт checksum
Показания воды передаются в BCD-формате: каждый байт содержит две десятичные цифры. Затем полученное число делится на 1000, потому что значение передаётся в литрах, а в JSON я вывожу кубометры.
Поначалу основной запрос выглядел правильным:
CD 03 D1 52 EB 71 82
Адрес 03 D1 52 EB соответствовал заводскому номеру счётчика. Но в логах я начал видеть нестабильные ответы: иногда приходил полный корректный кадр, иногда кадр был обрезан с начала, иногда второй байт был искажён. Например вместо ожидаемого: 5A 03 D1 52 EB ... Могло прилететь:
03 D1 52 EB …
5A CB D1 52 EB …
5A 19 D1 52 EB …
То есть полезная часть кадра присутствовала, но начало ответа было нестабильным. Обычный парсер, который ждёт 5A 03 D1 52 EB, такие кадры справедливо отбрасывал как невалидные. Я добавил логирование raw_hex, last_error, last_ok_ms, чтобы видеть не только итоговое значение, но и реальные байты на входе.
Чтобы отделить проблему протокола от проблемы кода ESP32, я вынес RS-485-тесты на отдельный USB-RS485 адаптер, подключённый к Raspberry Pi. Это позволило посылать те же самые байтовые команды напрямую и смотреть чистый ответ счётчика.
Тест “только основной запрос” показал нестабильность: часть ответов была нормальной, часть приходила без стартового байта 5A или с искажением начала. Затем я проверил другую последовательность:
CD 00 00 00 00 96 96 запрос адреса
короткая пауза
CD 03 D1 52 EB 71 82 запрос основных данных
И эта схема дала стабильный результат: после адресного запроса основной 19-байтный ответ стал приходить корректно. Я назвал адресный запрос “прогревом” линии. По сути, это не получение данных ради адреса, а подготовительный обмен, после которого основной запрос Бетара стал воспроизводимым. В код ESP32 это было перенесено так:
Очистить входной UART-буфер.
Отправить CD 00 00 00 00 96 96.
Прочитать и отбросить ответ B5 03 D1 52 EB 11.
Подождать около 500 мс.
Снова очистить UART-буфер.
Отправить основной запрос CD 03 D1 52 EB 71 82.
Искать внутри полученного буфера корректный 19-байтный кадр 5A 03 D1 52 EB …
После этого Бетар начал стабильно отдавать показания. В логах рабочая последовательность выглядела так:
BETAR warmup TX CD 00 00 00 00 96 96
BETAR warmup RX raw=B5 03 D1 52 EB 11
BETAR data TX CD 03 D1 52 EB 71 82
BETAR data RX raw=5A 03 D1 52 EB …
BETAR ok forward=…
Позже это подтвердилось длительной работой: Бетар продолжал отдавать корректные кадры с нормальным warmup-ответом и валидным основным 19-байтным ответом. В логах были повторяющиеся успешные циклы B5 03 D1 52 EB 11 → 5A 03 D1 52 EB ... → BETAR ok forward=...
Рабочий код получился вот таким:
Скрытый текст
#include <Arduino.h> /* ESP32-ETH01 / WT32-ETH01 Ethernet-настройки. ВАЖНО: Эти define должны быть ДО #include <ETH.h> */ #define ETH_PHY_TYPE ETH_PHY_LAN8720 #define ETH_PHY_ADDR 1 #define ETH_PHY_MDC 23 #define ETH_PHY_MDIO 18 #define ETH_PHY_POWER 16 #define ETH_CLK_MODE ETH_CLOCK_GPIO0_IN #include <ETH.h> #include <WebServer.h> #include <ArduinoOTA.h> /* ============================================================ RS-485 / БЕТАР ============================================================ */ HardwareSerial RS485(2); WebServer server(80); /* Подключение MAX3485: MAX3485 TXD -> ESP32 GPIO36 MAX3485 RXD -> ESP32 GPIO14 */ #define RS485_RX_PIN 36 #define RS485_TX_PIN 14 /* У используемого MAX3485-модуля автонаправление. DE/RE не используется. */ #define DE_RE_PIN -1 /* Статический IP ESP32. */ IPAddress localIP(192, 168, 1, 60); IPAddress gateway(192, 168, 1, 1); IPAddress subnet(255, 255, 255, 0); IPAddress dns1(192, 168, 1, 1); /* Прогревочный запрос адреса Бетар: CD 00 00 00 00 96 96 Ответ: B5 03 D1 52 EB 11 */ const uint8_t betarWarmupRequestData[] = { 0xCD, 0x00, 0x00, 0x00, 0x00, 0x96, 0x96 }; /* Основной запрос Бетар СГВЭ-15. Заводской номер: 64049899 Адрес: 03 D1 52 EB Запрос: CD 03 D1 52 EB 71 82 */ const uint8_t betarRequestData[] = { 0xCD, 0x03, 0xD1, 0x52, 0xEB, 0x71, 0x82 }; /* Автоопрос раз в минуту. */ const unsigned long betarPollInterval = 60000; unsigned long nextBetarPollMs = 0; /* Ethernet / OTA flags. */ bool ethConnected = false; bool otaStarted = false; /* Данные Бетар. */ bool betarValid = false; double betarForwardM3 = 0; double betarReverseM3 = 0; uint32_t betarMagnetSeconds = 0; uint8_t betarServiceByte = 0; unsigned long betarLastOkMs = 0; unsigned long betarLastPollMs = 0; String betarLastError = "not polled yet"; String betarLastRawHex = ""; String betarLastWarmupHex = ""; /* ============================================================ ЛЁГКИЙ LOG ============================================================ */ #define LOG_LINES 80 String logBuffer[LOG_LINES]; int logIndex = 0; bool logWrapped = false; void addLog(String msg) { String line = String(millis()) + " ms | " + msg; logBuffer[logIndex] = line; logIndex++; if (logIndex >= LOG_LINES) { logIndex = 0; logWrapped = true; } Serial.println(line); } String makeLogText() { String out; out += "ESP32 ETH01 Betar RS485 log\n"; out += "uptime_ms="; out += String(millis()); out += "\n\n"; int start = logWrapped ? logIndex : 0; int count = logWrapped ? LOG_LINES : logIndex; for (int i = 0; i < count; i++) { int idx = (start + i) % LOG_LINES; out += logBuffer[idx]; out += "\n"; } return out; } /* ============================================================ HELPERS ============================================================ */ String jsonEscape(const String& s) { String out = ""; for (size_t i = 0; i < s.length(); i++) { char c = s[i]; if (c == '\\') out += "\\\\"; else if (c == '"') out += "\\\""; else if (c == '\n') out += "\\n"; else if (c == '\r') out += "\\r"; else if (c == '\t') out += "\\t"; else if ((uint8_t)c < 32) out += " "; else out += c; } return out; } String bytesToHex(const uint8_t *data, int len) { String s; for (int i = 0; i < len; i++) { if (data[i] < 0x10) s += "0"; s += String(data[i], HEX); if (i < len - 1) s += " "; } s.toUpperCase(); return s; } String byteToHex(uint8_t b) { String s; if (b < 0x10) s += "0"; s += String(b, HEX); s.toUpperCase(); return s; } void printHex(const uint8_t *data, int len) { Serial.println(bytesToHex(data, len)); } /* Контрольная сумма Бетар: сумма байтов с заданной позиции, младший байт результата. */ uint8_t checksum(const uint8_t *data, int start, int count) { uint16_t sum = 0; for (int i = start; i < start + count; i++) { sum += data[i]; } return sum & 0xFF; } /* Декодирование BCD-объёма. */ double decodeVolume(const uint8_t *b) { int digits[8]; int idx = 0; for (int i = 0; i < 4; i++) { int lo = b[i] & 0x0F; int hi = (b[i] >> 4) & 0x0F; if (lo > 9 || hi > 9) return -1.0; digits[idx++] = lo; digits[idx++] = hi; } long value = 0; for (int i = 7; i >= 0; i--) { value = value * 10 + digits[i]; } return value / 1000.0; } /* Чтение ответа RS-485. */ int readRs485Response(uint8_t *buf, int maxLen, unsigned long timeoutMs) { int len = 0; unsigned long start = millis(); while (millis() - start < timeoutMs && len < maxLen) { while (RS485.available() && len < maxLen) { buf[len++] = RS485.read(); } delay(1); } return len; } void clearRs485Input() { while (RS485.available()) { RS485.read(); } } void rs485WritePacket(const uint8_t *data, int len) { #if DE_RE_PIN >= 0 digitalWrite(DE_RE_PIN, HIGH); delayMicroseconds(200); #endif RS485.write(data, len); RS485.flush(); #if DE_RE_PIN >= 0 delayMicroseconds(500); digitalWrite(DE_RE_PIN, LOW); #endif } /* ============================================================ БЕТАР ============================================================ */ void warmupBetar() { uint8_t rx[32]; addLog("BETAR warmup start"); clearRs485Input(); addLog(String("BETAR warmup TX ") + bytesToHex(betarWarmupRequestData, sizeof(betarWarmupRequestData))); rs485WritePacket(betarWarmupRequestData, sizeof(betarWarmupRequestData)); int len = readRs485Response(rx, sizeof(rx), 1000); addLog(String("BETAR warmup RX bytes=") + String(len)); if (len > 0) { betarLastWarmupHex = bytesToHex(rx, len); addLog(String("BETAR warmup RX raw=") + betarLastWarmupHex); } else { betarLastWarmupHex = ""; addLog("BETAR warmup RX empty"); } } bool parseBetarFrame(uint8_t *buf, int len) { /* Нормальный основной ответ: 5A 03 D1 52 EB ... всего 19 байт */ for (int start = 0; start <= len - 19; start++) { if (buf[start] != 0x5A) continue; uint8_t *f = &buf[start]; if (f[1] != 0x03 || f[2] != 0xD1 || f[3] != 0x52 || f[4] != 0xEB) { continue; } uint8_t cs = checksum(f, 1, 17); if (cs != f[18]) { betarLastError = "checksum error"; addLog(String("BETAR error checksum calc=") + byteToHex(cs) + " frame=" + byteToHex(f[18])); return false; } double forward = decodeVolume(&f[5]); double reverse = decodeVolume(&f[9]); if (forward < 0 || reverse < 0) { betarLastError = "bad BCD volume"; addLog("BETAR error bad BCD volume"); return false; } betarForwardM3 = forward; betarReverseM3 = reverse; betarMagnetSeconds = ((uint32_t)f[13]) | ((uint32_t)f[14] << 8) | ((uint32_t)f[15] << 16) | ((uint32_t)f[16] << 24); betarServiceByte = f[17]; betarValid = true; betarLastOkMs = millis(); betarLastError = "ok"; addLog(String("BETAR ok forward=") + String(betarForwardM3, 3) + " reverse=" + String(betarReverseM3, 3)); return true; } betarLastError = "no valid 5A frame"; addLog("BETAR error no valid 5A frame"); return false; } bool pollBetarMeter() { uint8_t rx[64]; betarLastPollMs = millis(); addLog("BETAR poll start"); /* 1. Прогрев адресным запросом. */ warmupBetar(); /* 2. Пауза как в стабильном тесте. */ delay(500); /* 3. Основной запрос. */ clearRs485Input(); addLog(String("BETAR data TX ") + bytesToHex(betarRequestData, sizeof(betarRequestData))); rs485WritePacket(betarRequestData, sizeof(betarRequestData)); int len = readRs485Response(rx, sizeof(rx), 1000); addLog(String("BETAR data RX bytes=") + String(len)); if (len > 0) { betarLastRawHex = bytesToHex(rx, len); addLog(String("BETAR data RX raw=") + betarLastRawHex); return parseBetarFrame(rx, len); } else { betarLastRawHex = ""; betarLastError = "no response"; addLog("BETAR error no response"); return false; } } /* ============================================================ JSON ============================================================ */ String makeJson() { String json = "{"; json += "\"device\":\"esp32_eth01_betar\","; json += "\"eth_connected\":"; json += ethConnected ? "true" : "false"; json += ","; json += "\"ip\":\""; json += ETH.localIP().toString(); json += "\","; json += "\"ota_started\":"; json += otaStarted ? "true" : "false"; json += ","; json += "\"uptime_ms\":"; json += String(millis()); json += ","; json += "\"auto\":{"; json += "\"betar_interval_ms\":"; json += String(betarPollInterval); json += ","; json += "\"next_betar_due_ms\":"; json += String(nextBetarPollMs); json += "},"; json += "\"betar\":{"; json += "\"device\":\"betar_sgve_15\","; json += "\"valid\":"; json += betarValid ? "true" : "false"; json += ","; json += "\"forward_m3\":"; json += String(betarForwardM3, 3); json += ","; json += "\"reverse_m3\":"; json += String(betarReverseM3, 3); json += ","; json += "\"magnet_seconds\":"; json += String(betarMagnetSeconds); json += ","; json += "\"service_byte\":\"0x"; json += byteToHex(betarServiceByte); json += "\","; json += "\"last_error\":\""; json += jsonEscape(betarLastError); json += "\","; json += "\"last_poll_ms\":"; json += String(betarLastPollMs); json += ","; json += "\"last_ok_ms\":"; json += String(betarLastOkMs); json += ","; json += "\"warmup_raw_hex\":\""; json += jsonEscape(betarLastWarmupHex); json += "\","; json += "\"raw_hex\":\""; json += jsonEscape(betarLastRawHex); json += "\""; json += "}"; json += "}"; return json; } /* ============================================================ HTTP ============================================================ */ void handleApi() { server.send(200, "application/json; charset=utf-8", makeJson()); } void handleLog() { server.send(200, "text/plain; charset=utf-8", makeLogText()); } void handleRoot() { String html; html += "<!doctype html><html><head><meta charset='utf-8'>"; html += "<meta http-equiv='refresh' content='10'>"; html += "<title>ESP32 ETH01 Betar</title>"; html += "</head><body>"; html += "<h2>ESP32 ETH01 Betar RS-485</h2>"; html += "<pre>"; html += makeJson(); html += "</pre>"; html += "<p>"; html += "<a href='/api'>/api</a> | "; html += "<a href='/json'>/json</a> | "; html += "<a href='/poll'>/poll Betar</a> | "; html += "<a href='/log'>/log</a>"; html += "</p>"; html += "</body></html>"; server.send(200, "text/html; charset=utf-8", html); } void handlePollBetar() { pollBetarMeter(); nextBetarPollMs = millis() + betarPollInterval; server.send(200, "application/json; charset=utf-8", makeJson()); } /* ============================================================ OTA ============================================================ */ void startOTA() { if (otaStarted) return; ArduinoOTA.setHostname("betar-esp32"); ArduinoOTA.setPort(3232); ArduinoOTA.setPassword("12345678"); ArduinoOTA.onStart([]() { addLog("OTA start"); Serial.println("OTA start"); }); ArduinoOTA.onEnd([]() { addLog("OTA end"); Serial.println("OTA end"); }); ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) { Serial.printf("OTA progress: %u%%\r", (progress * 100) / total); }); ArduinoOTA.onError([](ota_error_t error) { addLog(String("OTA error code=") + String((int)error)); Serial.print("OTA error: "); Serial.println((int)error); }); ArduinoOTA.begin(); otaStarted = true; Serial.println("ArduinoOTA started on UDP port 3232"); addLog("ArduinoOTA started on UDP port 3232"); } /* ============================================================ Ethernet events ============================================================ */ void onEvent(arduino_event_id_t event) { switch (event) { case ARDUINO_EVENT_ETH_START: Serial.println("ETH Started"); ETH.setHostname("betar-esp32"); addLog("ETH Started"); break; case ARDUINO_EVENT_ETH_CONNECTED: Serial.println("ETH Connected"); addLog("ETH Connected"); break; case ARDUINO_EVENT_ETH_GOT_IP: Serial.print("ETH IP: "); Serial.println(ETH.localIP()); ethConnected = true; addLog(String("ETH GOT IP ") + ETH.localIP().toString()); startOTA(); break; case ARDUINO_EVENT_ETH_DISCONNECTED: Serial.println("ETH Disconnected"); ethConnected = false; addLog("ETH Disconnected"); break; case ARDUINO_EVENT_ETH_STOP: Serial.println("ETH Stopped"); ethConnected = false; addLog("ETH Stopped"); break; default: break; } } /* ============================================================ Setup / Loop ============================================================ */ void setup() { Serial.begin(115200); delay(1000); Serial.println(); Serial.println("ESP32 ETH01 Betar RS485 reader"); addLog("BOOT"); #if DE_RE_PIN >= 0 pinMode(DE_RE_PIN, OUTPUT); digitalWrite(DE_RE_PIN, LOW); #endif /* UART2 для RS-485. */ RS485.begin(9600, SERIAL_8N1, RS485_RX_PIN, RS485_TX_PIN); /* Ethernet. */ Network.onEvent(onEvent); ETH.begin( ETH_PHY_TYPE, ETH_PHY_ADDR, ETH_PHY_MDC, ETH_PHY_MDIO, ETH_PHY_POWER, ETH_CLK_MODE ); if (!ETH.config(localIP, gateway, subnet, dns1)) { Serial.println("ETH static IP config failed"); addLog("ETH static IP config failed"); } /* HTTP. */ server.on("/", handleRoot); server.on("/api", handleApi); server.on("/json", handleApi); server.on("/poll", handlePollBetar); server.on("/poll_betar", handlePollBetar); server.on("/log", handleLog); server.begin(); Serial.println("HTTP server started"); Serial.println("Open: http://192.168.1.60/api"); addLog("HTTP server started"); /* Первый опрос сразу после старта. */ pollBetarMeter(); nextBetarPollMs = millis() + betarPollInterval; } void loop() { if (otaStarted) { ArduinoOTA.handle(); } server.handleClient(); unsigned long now = millis(); if ((long)(now - nextBetarPollMs) >= 0) { pollBetarMeter(); nextBetarPollMs = millis() + betarPollInterval; } }
И в результате выполнения кода ESP32 показывал по адресу http://192.168.1.60/api следующие показания в JSON:
{ “device”: “esp32_eth01_betar”, “eth_connected”: true, “ip”: “192.168.1.60”, “ota_started”: true, “uptime_ms”: 6508849, “auto”: { “betar_interval_ms”: 60000, “next_betar_due_ms”: 6512228 }, “betar”: { “device”: “betar_sgve_15”, “valid”: true, “forward_m3”: 31.947, “reverse_m3”: 0.000, “magnet_seconds”: 0, “service_byte”: “0x00”, “last_error”: “ok”, “last_poll_ms”: 6449702, “last_ok_ms”: 6452216, “warmup_raw_hex”: “B5 03 D1 52 EB 11”, “raw_hex”: “5A 03 D1 52 EB 47 19 03 00 00 00 00 00 00 00 00 00 00 74” } }
Задача на первый взгляд выглядела простой: взять ESP32-ETH01, подключить к ней счётчик воды Бетар СГВЭ-15 по RS-485, параллельно читать электросчётчик МИР по BLE, а результат отдавать по Ethernet в виде JSON. В итоговом варианте устройство должно было работать автономно: Бетар опрашивается по расписанию, МИР опрашивается по BLE, все данные доступны через /api, а прошивка обновляется по OTA.

На практике самым сложным оказался не сам JSON и не Ethernet, а поведение двух совершенно разных интерфейсов на одном ESP32: короткий и чувствительный к таймингам RS-485-обмен с Бетаром и длинный, многошаговый BLE-диалог со счётчиком МИР.
Отдельно пришлось учитывать, что BLE-опрос МИР занимает заметное время. Это не миллисекундный обмен, а длинная серия команд и notify-ответов. Поэтому я не стал сразу делать плотное чередование 30/30 секунд. Сначала МИР запускался только вручную через /poll_mir, чтобы убедиться, что после BLE Бетар продолжает стабильно читаться. Когда работоспособность подтвердилась - сделал автоматический режим более консервативным
Бетар: каждые 60 секунд
МИР: каждые 180 секунд первый опрос
МИР: примерно через 30 секунд после старта
Такой режим снизил риск наложения длинного BLE-опроса на RS-485-обмен и дал возможность наблюдать систему через /api. На этапе диагностики /log был крайне полезен. Туда выводил:
RS-485 TX/RX warmup
RX основной raw_hex Бетара
BLE notify HEX
BLE notify TXT
результаты парсинга TOTAL/T1/T2
Но подробный BLE HEX создавал слишком большой объём текста. Через длительное время /api продолжал открываться, а /log мог перестать отвечать или стать тяжёлым. Причина не в переполнении массива как таковом — лог был кольцевым, — а в том, что большой String мог перестать отвечать или стать тяжёлым, что для ESP32 со временем приводит к нагрузке на heap и фрагментации памяти.
После того как парсинг МИР был отлажен, подробные HEX-строки убрал из постоянного режима, размер кольцевого лога уменьшил, а в логе оставили только ключевые события:
BETAR ok
MIR done status
MIR saved total/t1/t2
ошибки
краткие диагностические строки
Итоговая архитектура
В финальном виде ESP32-ETH01 делает следующее:
Поднимает Ethernet со статическим IP 192.168.1.60.
Запускает HTTP API и OTA.
Опрос Бетар:
адресный warmup-запрос;
ожидание;
основной запрос;
поиск 19-байтного кадра;
проверка адреса и checksum;
декодирование BCD-показаний.
4. Опрос МИР:
BLE-подключение;
авторизация;
подписка на notify;
сканирование энергетических страниц до total + T1 + T2;
сканирование параметров до даты, времени, тока и напряжения.
5. Публикация общего состояния в JSON.
Через 20 часов работы система продолжала отдавать корректный /api: Бетар был valid:true, МИР имел last_read_ok:true, а поля poll_total_found, poll_t1_found, poll_t2_found были true. Это означало, что оба канала — RS-485 и BLE — работают совместно и не мешают друг другу.
{"device":"esp32_eth01_betar_mir","eth_connected":true,"ip":"192.168.1.60","ota_started":true,"uptime_ms":52470631,"auto":{"betar_interval_ms":60000,"mir_interval_ms":180000,"next_betar_due_ms":52488178,"next_mir_due_ms":52480587},"betar":{"device":"betar_sgve_15","valid":true,"forward_m3":32.101,"reverse_m3":0.000,"magnet_seconds":0,"service_byte":"0x00","last_error":"ok","last_poll_ms":52425652,"last_ok_ms":52428166,"warmup_raw_hex":"B5 03 D1 52 EB 11","raw_hex":"5A 03 D1 52 EB 01 21 03 00 00 00 00 00 00 00 00 00 00 36"},"mir":{"device":"mir_ble","meter_mac":"E4:06:BF:87:CD:69","pin_used":58525,"last_read_ok":true,"last_error":"ok","last_poll_ms":52272489,"last_ok_ms":52300575,"notify_count":11,"poll_total_found":true,"poll_t1_found":true,"poll_t2_found":true,"total_kwh":629.18,"t1_kwh":469.49,"t2_kwh":159.68,"total_last_ok_ms":52285859,"t1_last_ok_ms":52287269,"t2_last_ok_ms":52281276,"date":"08.05.26","time":"11:34:17","current_a":2.88,"voltage_v":230.18,"last_text":"D я \" НАПРЯЖЕНИЕ ФАЗЫ 230.18 В 1v?"}}
Итоговый код:
Скрытый текст
#include <Arduino.h> /* ESP32-ETH01 / WT32-ETH01 Ethernet-настройки. Эти define должны быть ДО #include <ETH.h> */ #define ETH_PHY_TYPE ETH_PHY_LAN8720 #define ETH_PHY_ADDR 1 #define ETH_PHY_MDC 23 #define ETH_PHY_MDIO 18 #define ETH_PHY_POWER 16 #define ETH_CLK_MODE ETH_CLOCK_GPIO0_IN #include <ETH.h> #include <WebServer.h> #include <ArduinoOTA.h> #include <NimBLEDevice.h> #include <math.h> /* ============================================================ RS-485 / БЕТАР ============================================================ */ HardwareSerial RS485(2); WebServer server(80); #define RS485_RX_PIN 36 #define RS485_TX_PIN 14 #define DE_RE_PIN -1 IPAddress localIP(192, 168, 1, 60); IPAddress gateway(192, 168, 1, 1); IPAddress subnet(255, 255, 255, 0); IPAddress dns1(192, 168, 1, 1); const uint8_t betarWarmupRequestData[] = { 0xCD, 0x00, 0x00, 0x00, 0x00, 0x96, 0x96 }; const uint8_t betarRequestData[] = { 0xCD, 0x03, 0xD1, 0x52, 0xEB, 0x71, 0x82 }; const unsigned long betarPollInterval = 60000; const unsigned long mirPollInterval = 180000; unsigned long nextBetarPollMs = 0; unsigned long nextMirPollMs = 0; bool ethConnected = false; bool otaStarted = false; bool insideHttpHandler = false; bool betarValid = false; double betarForwardM3 = 0; double betarReverseM3 = 0; uint32_t betarMagnetSeconds = 0; uint8_t betarServiceByte = 0; unsigned long betarLastOkMs = 0; unsigned long betarLastPollMs = 0; String betarLastError = "not polled yet"; String betarLastRawHex = ""; String betarLastWarmupHex = ""; /* ============================================================ BLE / МИР ============================================================ */ static NimBLEAddress mirMeterAddr(std::string("E4:06:BF:87:CD:69"), BLE_ADDR_PUBLIC); uint32_t MIR_PIN_CODE = 58525; static const char* SVC_5336 = "53367898-fdd5-46cc-81e6-b79a008ce1ad"; static const char* SVC_4880 = "4880c12c-fdcb-4077-8920-a450d7f9b907"; static const char* UUID_D24A = "d24a5138-1448-48ea-a983-f7df274c6d89"; static const char* UUID_B3F7 = "b3f7e595-2951-42fa-879e-0d9dfa5e846e"; static const char* UUID_FEC2 = "fec26ec4-6d71-4442-9f81-55bc21d658d6"; static NimBLEClient* mirClient = nullptr; static NimBLERemoteCharacteristic* ch_d24a = nullptr; static NimBLERemoteCharacteristic* ch_b3f7 = nullptr; static NimBLERemoteCharacteristic* ch_fec2 = nullptr; bool mirLastReadOk = false; String mirLastError = "not polled yet"; unsigned long mirLastPollMs = 0; unsigned long mirLastOkMs = 0; String mirLastText = ""; uint32_t mirNotifyCount = 0; /* Флаги именно текущего цикла опроса МИР. */ bool mirThisPollTotal = false; bool mirThisPollT1 = false; bool mirThisPollT2 = false; bool mirThisPollDate = false; bool mirThisPollTime = false; bool mirThisPollCurrent = false; bool mirThisPollVoltage = false; struct MirData { bool total_valid = false; bool t1_valid = false; bool t2_valid = false; bool date_valid = false; bool time_valid = false; bool current_valid = false; bool voltage_valid = false; float total_kwh = 0.0f; float t1_kwh = 0.0f; float t2_kwh = 0.0f; float current_a = 0.0f; float voltage_v = 0.0f; unsigned long total_last_ok_ms = 0; unsigned long t1_last_ok_ms = 0; unsigned long t2_last_ok_ms = 0; unsigned long current_last_ok_ms = 0; unsigned long voltage_last_ok_ms = 0; String date = ""; String time = ""; }; MirData mirData; /* ============================================================ LOG ============================================================ */ #define LOG_LINES 80 String logBuffer[LOG_LINES]; int logIndex = 0; bool logWrapped = false; void addLog(String msg) { String line = String(millis()) + " ms | " + msg; logBuffer[logIndex] = line; logIndex++; if (logIndex >= LOG_LINES) { logIndex = 0; logWrapped = true; } Serial.println(line); } String makeLogText() { String out; out += "ESP32 ETH01 Betar RS485 + MIR BLE auto debug log\n"; out += "uptime_ms="; out += String(millis()); out += "\n\n"; int start = logWrapped ? logIndex : 0; int count = logWrapped ? LOG_LINES : logIndex; for (int i = 0; i < count; i++) { int idx = (start + i) % LOG_LINES; out += logBuffer[idx]; out += "\n"; } return out; } /* ============================================================ COMMON HELPERS ============================================================ */ String jsonEscape(const String& s) { String out = ""; for (size_t i = 0; i < s.length(); i++) { char c = s[i]; if (c == '\\') out += "\\\\"; else if (c == '"') out += "\\\""; else if (c == '\n') out += "\\n"; else if (c == '\r') out += "\\r"; else if (c == '\t') out += "\\t"; else if ((uint8_t)c < 32) out += " "; else out += c; } return out; } void serviceBackground(unsigned long ms) { unsigned long start = millis(); while (millis() - start < ms) { if (otaStarted) { ArduinoOTA.handle(); } if (!insideHttpHandler) { server.handleClient(); } delay(5); } } /* ============================================================ БЕТАР HELPERS ============================================================ */ uint8_t checksum(const uint8_t *data, int start, int count) { uint16_t sum = 0; for (int i = start; i < start + count; i++) { sum += data[i]; } return sum & 0xFF; } double decodeVolume(const uint8_t *b) { int digits[8]; int idx = 0; for (int i = 0; i < 4; i++) { int lo = b[i] & 0x0F; int hi = (b[i] >> 4) & 0x0F; if (lo > 9 || hi > 9) return -1.0; digits[idx++] = lo; digits[idx++] = hi; } long value = 0; for (int i = 7; i >= 0; i--) { value = value * 10 + digits[i]; } return value / 1000.0; } String bytesToHex(const uint8_t *data, int len) { String s; for (int i = 0; i < len; i++) { if (data[i] < 0x10) s += "0"; s += String(data[i], HEX); if (i < len - 1) s += " "; } s.toUpperCase(); return s; } String byteToHex(uint8_t b) { String s; if (b < 0x10) s += "0"; s += String(b, HEX); s.toUpperCase(); return s; } void printHex(const uint8_t *data, int len) { Serial.println(bytesToHex(data, len)); } int readRs485Response(uint8_t *buf, int maxLen, unsigned long timeoutMs) { int len = 0; unsigned long start = millis(); while (millis() - start < timeoutMs && len < maxLen) { while (RS485.available() && len < maxLen) { buf[len++] = RS485.read(); } delay(1); } return len; } void clearRs485Input() { while (RS485.available()) { RS485.read(); } } void rs485WritePacket(const uint8_t *data, int len) { #if DE_RE_PIN >= 0 digitalWrite(DE_RE_PIN, HIGH); delayMicroseconds(200); #endif RS485.write(data, len); RS485.flush(); #if DE_RE_PIN >= 0 delayMicroseconds(500); digitalWrite(DE_RE_PIN, LOW); #endif } void warmupBetar() { uint8_t rx[32]; addLog("BETAR warmup start"); clearRs485Input(); addLog(String("BETAR warmup TX ") + bytesToHex(betarWarmupRequestData, sizeof(betarWarmupRequestData))); rs485WritePacket(betarWarmupRequestData, sizeof(betarWarmupRequestData)); int len = readRs485Response(rx, sizeof(rx), 1000); addLog(String("BETAR warmup RX bytes=") + String(len)); if (len > 0) { betarLastWarmupHex = bytesToHex(rx, len); addLog(String("BETAR warmup RX raw=") + betarLastWarmupHex); } else { betarLastWarmupHex = ""; addLog("BETAR warmup RX empty"); } } bool parseBetarFrame(uint8_t *buf, int len) { for (int start = 0; start <= len - 19; start++) { if (buf[start] != 0x5A) continue; uint8_t *f = &buf[start]; if (f[1] != 0x03 || f[2] != 0xD1 || f[3] != 0x52 || f[4] != 0xEB) { continue; } uint8_t cs = checksum(f, 1, 17); if (cs != f[18]) { betarLastError = "checksum error"; addLog(String("BETAR error: checksum calc=") + byteToHex(cs) + " frame=" + byteToHex(f[18])); return false; } double forward = decodeVolume(&f[5]); double reverse = decodeVolume(&f[9]); if (forward < 0 || reverse < 0) { betarLastError = "bad BCD volume"; addLog("BETAR error: bad BCD volume"); return false; } betarForwardM3 = forward; betarReverseM3 = reverse; betarMagnetSeconds = ((uint32_t)f[13]) | ((uint32_t)f[14] << 8) | ((uint32_t)f[15] << 16) | ((uint32_t)f[16] << 24); betarServiceByte = f[17]; betarValid = true; betarLastOkMs = millis(); betarLastError = "ok"; addLog(String("BETAR ok forward=") + String(betarForwardM3, 3) + " reverse=" + String(betarReverseM3, 3)); return true; } betarLastError = "no valid 5A frame"; addLog("BETAR error: no valid 5A frame"); return false; } bool pollBetarMeter() { uint8_t rx[64]; betarLastPollMs = millis(); addLog("BETAR poll start"); warmupBetar(); delay(500); clearRs485Input(); addLog(String("BETAR data TX ") + bytesToHex(betarRequestData, sizeof(betarRequestData))); rs485WritePacket(betarRequestData, sizeof(betarRequestData)); int len = readRs485Response(rx, sizeof(rx), 1000); addLog(String("BETAR data RX bytes=") + String(len)); if (len > 0) { betarLastRawHex = bytesToHex(rx, len); addLog(String("BETAR data RX raw=") + betarLastRawHex); return parseBetarFrame(rx, len); } else { betarLastRawHex = ""; betarLastError = "no response"; addLog("BETAR error: no response"); return false; } } /* ============================================================ МИР HELPERS ============================================================ */ void resetMirThisPollFlags() { mirThisPollTotal = false; mirThisPollT1 = false; mirThisPollT2 = false; mirThisPollDate = false; mirThisPollTime = false; mirThisPollCurrent = false; mirThisPollVoltage = false; } std::string cp1251ToUtf8(const std::string& in) { String out = ""; for (uint8_t c : in) { if (c == 0x00) { out += ' '; } else if (c < 0x80) { out += (char)c; } else if (c == 0xA8) { out += "\xD0\x81"; } else if (c == 0xB8) { out += "\xD1\x91"; } else if (c >= 0xC0 && c <= 0xFF) { uint16_t unicode = 0x0410 + (c - 0xC0); out += char(0xD0 + (unicode > 0x043F ? 1 : 0)); if (unicode <= 0x043F) { out += char(0x80 + (unicode - 0x0400)); } else { out += char(0x80 + (unicode - 0x0440)); } } else { out += '?'; } } return std::string(out.c_str()); } String normalizeText(const String& input) { String out = ""; for (size_t i = 0; i < input.length(); i++) { char c = input[i]; if ((uint8_t)c >= 32 || c == '\n' || c == '\r' || c == '\t') { out += c; } else { out += ' '; } } String compact = ""; bool prevSpace = false; for (size_t i = 0; i < out.length(); i++) { char c = out[i]; bool isSpace = (c == ' ' || c == '\t' || c == '\r' || c == '\n'); if (isSpace) { if (!prevSpace) compact += ' '; prevSpace = true; } else { compact += c; prevSpace = false; } } compact.trim(); return compact; } bool mirTextIsEnergy(const String& text) { return text.indexOf("Актив.эн") >= 0; } bool mirTextIsT1(const String& text) { return text.indexOf("т.1") >= 0 || text.indexOf("т1") >= 0; } bool mirTextIsT2(const String& text) { return text.indexOf("т.2") >= 0 || text.indexOf("т2") >= 0; } float extractLastFloat(const String& text) { float found = NAN; int i = 0; while (i < (int)text.length()) { while (i < (int)text.length() && !isdigit(text[i])) i++; if (i >= (int)text.length()) break; int start = i; bool dotSeen = false; while (i < (int)text.length()) { char c = text[i]; if (isdigit(c)) { i++; continue; } if (c == '.' && !dotSeen) { dotSeen = true; i++; continue; } break; } String token = text.substring(start, i); if (token.indexOf('.') >= 0) { float v = token.toFloat(); if (v > 0.0f) found = v; } } return found; } String extractDate(const String& text) { for (size_t i = 0; i + 7 < text.length(); i++) { if (isdigit(text[i]) && isdigit(text[i + 1]) && text[i + 2] == '.' && isdigit(text[i + 3]) && isdigit(text[i + 4]) && text[i + 5] == '.' && isdigit(text[i + 6]) && isdigit(text[i + 7])) { return text.substring(i, i + 8); } } return ""; } String extractTime(const String& text) { for (size_t i = 0; i + 4 < text.length(); i++) { if (i + 7 < text.length() && isdigit(text[i]) && isdigit(text[i + 1]) && text[i + 2] == ':' && isdigit(text[i + 3]) && isdigit(text[i + 4]) && text[i + 5] == ':' && isdigit(text[i + 6]) && isdigit(text[i + 7])) { return text.substring(i, i + 8); } if (isdigit(text[i]) && isdigit(text[i + 1]) && text[i + 2] == ':' && isdigit(text[i + 3]) && isdigit(text[i + 4])) { return text.substring(i, i + 5); } } return ""; } void buildAuthPayload(uint32_t pin, uint8_t out[4]) { out[0] = pin & 0xFF; out[1] = (pin >> 8) & 0xFF; out[2] = 0x00; out[3] = 0x00; } void parseMirText(const String& text) { if (mirTextIsEnergy(text) && mirTextIsT1(text)) { float v = extractLastFloat(text); if (!isnan(v)) { mirData.t1_kwh = v; mirData.t1_valid = true; mirData.t1_last_ok_ms = millis(); mirThisPollT1 = true; addLog(String("MIR PARSE T1=") + String(v, 2)); } else { addLog("MIR PARSE T1 failed"); } return; } if (mirTextIsEnergy(text) && mirTextIsT2(text)) { float v = extractLastFloat(text); if (!isnan(v)) { mirData.t2_kwh = v; mirData.t2_valid = true; mirData.t2_last_ok_ms = millis(); mirThisPollT2 = true; addLog(String("MIR PARSE T2=") + String(v, 2)); } else { addLog("MIR PARSE T2 failed"); } return; } if (mirTextIsEnergy(text) && text.indexOf("прям") >= 0 && !mirTextIsT1(text) && !mirTextIsT2(text)) { float v = extractLastFloat(text); if (!isnan(v)) { mirData.total_kwh = v; mirData.total_valid = true; mirData.total_last_ok_ms = millis(); mirThisPollTotal = true; addLog(String("MIR PARSE TOTAL=") + String(v, 2)); } else { addLog("MIR PARSE TOTAL failed"); } return; } if (text.indexOf("ДАТА") >= 0) { String d = extractDate(text); if (d.length() > 0) { mirData.date = d; mirData.date_valid = true; mirThisPollDate = true; addLog(String("MIR PARSE DATE=") + d); } return; } if (text.indexOf("ВРЕМЯ") >= 0) { String t = extractTime(text); if (t.length() > 0) { mirData.time = t; mirData.time_valid = true; mirThisPollTime = true; addLog(String("MIR PARSE TIME=") + t); } return; } if (text.indexOf("ТОК ФАЗЫ") >= 0) { float v = extractLastFloat(text); if (!isnan(v)) { mirData.current_a = v; mirData.current_valid = true; mirData.current_last_ok_ms = millis(); mirThisPollCurrent = true; addLog(String("MIR PARSE CURRENT=") + String(v, 2)); } return; } if (text.indexOf("НАПРЯЖЕНИЕ") >= 0 && text.indexOf("ФАЗЫ") >= 0) { float v = extractLastFloat(text); if (!isnan(v)) { mirData.voltage_v = v; mirData.voltage_valid = true; mirData.voltage_last_ok_ms = millis(); mirThisPollVoltage = true; addLog(String("MIR PARSE VOLTAGE=") + String(v, 2)); } return; } } void mirNotifyCB( NimBLERemoteCharacteristic* pRemoteCharacteristic, uint8_t* pData, size_t length, bool isNotify) { mirNotifyCount++; String rawHex = bytesToHex(pData, (int)length); std::string raw((char*)pData, length); std::string utf8 = cp1251ToUtf8(raw); String text = normalizeText(String(utf8.c_str())); mirLastText = text; addLog(String("MIR RX #") + String(mirNotifyCount) + " TXT " + text); parseMirText(text); } bool mirSendFec2Command(const uint8_t* cmd, size_t len, uint32_t waitMs) { if (!ch_fec2) { mirLastError = "fec2 characteristic missing"; return false; } addLog(String("MIR TX ") + bytesToHex(cmd, (int)len)); bool ok = ch_fec2->writeValue(cmd, len, false); if (!ok) { mirLastError = "write fec2 failed"; addLog("MIR error: write fec2 failed"); return false; } serviceBackground(waitMs); return true; } void mirDisconnectClient() { if (mirClient) { if (mirClient->isConnected()) { mirClient->disconnect(); } NimBLEDevice::deleteClient(mirClient); mirClient = nullptr; } ch_d24a = nullptr; ch_b3f7 = nullptr; ch_fec2 = nullptr; } bool mirConnectAndSetup() { addLog("MIR connect start"); mirClient = NimBLEDevice::createClient(); if (!mirClient->connect(mirMeterAddr)) { mirLastError = "connect failed"; addLog(String("MIR error: ") + mirLastError); return false; } addLog("MIR connected"); NimBLERemoteService* svc5336 = mirClient->getService(SVC_5336); NimBLERemoteService* svc4880 = mirClient->getService(SVC_4880); if (!svc5336 || !svc4880) { mirLastError = "service not found"; addLog(String("MIR error: ") + mirLastError); mirDisconnectClient(); return false; } ch_d24a = svc5336->getCharacteristic(UUID_D24A); ch_b3f7 = svc4880->getCharacteristic(UUID_B3F7); ch_fec2 = svc4880->getCharacteristic(UUID_FEC2); if (!ch_d24a || !ch_b3f7 || !ch_fec2) { mirLastError = "characteristic not found"; addLog(String("MIR error: ") + mirLastError); mirDisconnectClient(); return false; } uint8_t one = 0x01; if (!ch_b3f7->writeValue(&one, 1, true)) { mirLastError = "write b3f7 failed"; addLog(String("MIR error: ") + mirLastError); mirDisconnectClient(); return false; } uint8_t auth[4]; buildAuthPayload(MIR_PIN_CODE, auth); if (!ch_d24a->writeValue(auth, 4, true)) { mirLastError = "write d24a failed"; addLog(String("MIR error: ") + mirLastError); mirDisconnectClient(); return false; } if (!ch_fec2->canNotify()) { mirLastError = "fec2 notify unsupported"; addLog(String("MIR error: ") + mirLastError); mirDisconnectClient(); return false; } if (!ch_fec2->subscribe(true, mirNotifyCB)) { mirLastError = "subscribe failed"; addLog(String("MIR error: ") + mirLastError); mirDisconnectClient(); return false; } serviceBackground(500); mirLastError = "connected"; addLog("MIR auth and notify ok"); return true; } /* Чтение МИР: сначала заходим в энергию и крутим NEXT до TOTAL+T1+T2, потом параметры. */ void mirReadMeterData() { static const uint8_t cmd_time[] = {0x00, 0x01, 0xFD, 0xC1, 0x1F}; static const uint8_t cmd_energy[] = {0x00, 0x01, 0xEE, 0xE3, 0x4D}; static const uint8_t cmd_next[] = {0x00, 0x01, 0x08, 0x7E, 0xA5}; static const uint8_t cmd_params[] = {0x00, 0x01, 0x02, 0xDF, 0xEF}; mirSendFec2Command(cmd_time, sizeof(cmd_time), 1500); addLog("MIR energy scan start"); mirSendFec2Command(cmd_energy, sizeof(cmd_energy), 1500); for (int i = 0; i < 16; i++) { if (mirThisPollTotal && mirThisPollT1 && mirThisPollT2) { addLog(String("MIR energy scan complete at step ") + String(i)); break; } addLog(String("MIR energy next step ") + String(i + 1)); mirSendFec2Command(cmd_next, sizeof(cmd_next), 1500); } if (!(mirThisPollTotal && mirThisPollT1 && mirThisPollT2)) { addLog(String("MIR energy scan partial total=") + String(mirThisPollTotal ? "1" : "0") + " t1=" + String(mirThisPollT1 ? "1" : "0") + " t2=" + String(mirThisPollT2 ? "1" : "0")); } addLog("MIR params scan start"); mirSendFec2Command(cmd_params, sizeof(cmd_params), 1500); for (int i = 0; i < 8; i++) { if (mirThisPollDate && mirThisPollTime && mirThisPollCurrent && mirThisPollVoltage) { addLog(String("MIR params scan complete at step ") + String(i)); break; } addLog(String("MIR params next step ") + String(i + 1)); mirSendFec2Command(cmd_next, sizeof(cmd_next), 1500); } if (!(mirThisPollDate && mirThisPollTime && mirThisPollCurrent && mirThisPollVoltage)) { addLog(String("MIR params scan partial date=") + String(mirThisPollDate ? "1" : "0") + " time=" + String(mirThisPollTime ? "1" : "0") + " current=" + String(mirThisPollCurrent ? "1" : "0") + " voltage=" + String(mirThisPollVoltage ? "1" : "0")); } } bool pollMirMeter() { mirLastPollMs = millis(); mirLastReadOk = false; mirLastError = "reading"; addLog("MIR poll start"); mirLastText = ""; mirNotifyCount = 0; resetMirThisPollFlags(); mirDisconnectClient(); if (!mirConnectAndSetup()) { mirDisconnectClient(); mirLastReadOk = false; return false; } mirReadMeterData(); serviceBackground(1500); mirDisconnectClient(); bool energyOk = mirThisPollTotal && mirThisPollT1 && mirThisPollT2; mirLastReadOk = energyOk; mirLastOkMs = millis(); if (energyOk) { mirLastError = "ok"; } else { mirLastError = "partial energy"; } addLog(String("MIR done status=") + mirLastError); addLog(String("MIR this poll total=") + String(mirThisPollTotal ? "1" : "0") + " t1=" + String(mirThisPollT1 ? "1" : "0") + " t2=" + String(mirThisPollT2 ? "1" : "0")); addLog(String("MIR saved total=") + (mirData.total_valid ? String(mirData.total_kwh, 2) : String("null"))); addLog(String("MIR saved t1=") + (mirData.t1_valid ? String(mirData.t1_kwh, 2) : String("null"))); addLog(String("MIR saved t2=") + (mirData.t2_valid ? String(mirData.t2_kwh, 2) : String("null"))); return energyOk; } /* ============================================================ JSON ============================================================ */ String makeJson() { String json = "{"; json += "\"device\":\"esp32_eth01_betar_mir\","; json += "\"eth_connected\":"; json += ethConnected ? "true" : "false"; json += ","; json += "\"ip\":\""; json += ETH.localIP().toString(); json += "\","; json += "\"ota_started\":"; json += otaStarted ? "true" : "false"; json += ","; json += "\"uptime_ms\":"; json += String(millis()); json += ","; json += "\"auto\":{"; json += "\"betar_interval_ms\":"; json += String(betarPollInterval); json += ","; json += "\"mir_interval_ms\":"; json += String(mirPollInterval); json += ","; json += "\"next_betar_due_ms\":"; json += String(nextBetarPollMs); json += ","; json += "\"next_mir_due_ms\":"; json += String(nextMirPollMs); json += "},"; json += "\"betar\":{"; json += "\"device\":\"betar_sgve_15\","; json += "\"valid\":"; json += betarValid ? "true" : "false"; json += ","; json += "\"forward_m3\":"; json += String(betarForwardM3, 3); json += ","; json += "\"reverse_m3\":"; json += String(betarReverseM3, 3); json += ","; json += "\"magnet_seconds\":"; json += String(betarMagnetSeconds); json += ","; json += "\"service_byte\":\"0x"; json += byteToHex(betarServiceByte); json += "\","; json += "\"last_error\":\""; json += jsonEscape(betarLastError); json += "\","; json += "\"last_poll_ms\":"; json += String(betarLastPollMs); json += ","; json += "\"last_ok_ms\":"; json += String(betarLastOkMs); json += ","; json += "\"warmup_raw_hex\":\""; json += jsonEscape(betarLastWarmupHex); json += "\","; json += "\"raw_hex\":\""; json += jsonEscape(betarLastRawHex); json += "\""; json += "},"; json += "\"mir\":{"; json += "\"device\":\"mir_ble\","; json += "\"meter_mac\":\"E4:06:BF:87:CD:69\","; json += "\"pin_used\":"; json += String(MIR_PIN_CODE); json += ","; json += "\"last_read_ok\":"; json += mirLastReadOk ? "true" : "false"; json += ","; json += "\"last_error\":\""; json += jsonEscape(mirLastError); json += "\","; json += "\"last_poll_ms\":"; json += String(mirLastPollMs); json += ","; json += "\"last_ok_ms\":"; json += String(mirLastOkMs); json += ","; json += "\"notify_count\":"; json += String(mirNotifyCount); json += ","; json += "\"poll_total_found\":"; json += mirThisPollTotal ? "true" : "false"; json += ","; json += "\"poll_t1_found\":"; json += mirThisPollT1 ? "true" : "false"; json += ","; json += "\"poll_t2_found\":"; json += mirThisPollT2 ? "true" : "false"; json += ","; json += "\"total_kwh\":"; json += mirData.total_valid ? String(mirData.total_kwh, 2) : "null"; json += ","; json += "\"t1_kwh\":"; json += mirData.t1_valid ? String(mirData.t1_kwh, 2) : "null"; json += ","; json += "\"t2_kwh\":"; json += mirData.t2_valid ? String(mirData.t2_kwh, 2) : "null"; json += ","; json += "\"total_last_ok_ms\":"; json += String(mirData.total_last_ok_ms); json += ","; json += "\"t1_last_ok_ms\":"; json += String(mirData.t1_last_ok_ms); json += ","; json += "\"t2_last_ok_ms\":"; json += String(mirData.t2_last_ok_ms); json += ","; json += "\"date\":"; if (mirData.date_valid) { json += "\""; json += jsonEscape(mirData.date); json += "\""; } else { json += "null"; } json += ","; json += "\"time\":"; if (mirData.time_valid) { json += "\""; json += jsonEscape(mirData.time); json += "\""; } else { json += "null"; } json += ","; json += "\"current_a\":"; json += mirData.current_valid ? String(mirData.current_a, 2) : "null"; json += ","; json += "\"voltage_v\":"; json += mirData.voltage_valid ? String(mirData.voltage_v, 2) : "null"; json += ","; json += "\"last_text\":\""; json += jsonEscape(mirLastText); json += "\""; json += "}"; json += "}"; return json; } /* ============================================================ HTTP ============================================================ */ void handleApi() { server.send(200, "application/json; charset=utf-8", makeJson()); } void handleLog() { server.send(200, "text/plain; charset=utf-8", makeLogText()); } void handleRoot() { String html; html += "<!doctype html><html><head><meta charset='utf-8'>"; html += "<meta http-equiv='refresh' content='10'>"; html += "<title>ESP32 Betar + MIR</title>"; html += "</head><body>"; html += "<h2>ESP32 ETH01 Betar RS-485 + MIR BLE auto</h2>"; html += "<pre>"; html += makeJson(); html += "</pre>"; html += "<p>"; html += "<a href='/api'>/api</a> | "; html += "<a href='/json'>/json</a> | "; html += "<a href='/poll'>/poll Betar</a> | "; html += "<a href='/log'>/log</a>"; html += "</p>"; html += "</body></html>"; server.send(200, "text/html; charset=utf-8", html); } void handlePollBetar() { insideHttpHandler = true; pollBetarMeter(); insideHttpHandler = false; server.send(200, "application/json; charset=utf-8", makeJson()); } /* ============================================================ OTA ============================================================ */ void startOTA() { if (otaStarted) return; ArduinoOTA.setHostname("betar-esp32"); ArduinoOTA.setPort(3232); ArduinoOTA.setPassword("12345678"); ArduinoOTA.onStart([]() { addLog("OTA start"); Serial.println("OTA start"); }); ArduinoOTA.onEnd([]() { addLog("OTA end"); Serial.println("OTA end"); }); ArduinoOTA.onProgress([](unsigned int progress, unsigned int total) { Serial.printf("OTA progress: %u%%\r", (progress * 100) / total); }); ArduinoOTA.onError([](ota_error_t error) { addLog(String("OTA error code=") + String((int)error)); Serial.print("OTA error: "); Serial.println((int)error); }); ArduinoOTA.begin(); otaStarted = true; Serial.println("ArduinoOTA started on UDP port 3232"); addLog("ArduinoOTA started on UDP port 3232"); } /* ============================================================ ETHERNET EVENTS ============================================================ */ void onEvent(arduino_event_id_t event) { switch (event) { case ARDUINO_EVENT_ETH_START: Serial.println("ETH Started"); ETH.setHostname("betar-esp32"); addLog("ETH Started"); break; case ARDUINO_EVENT_ETH_CONNECTED: Serial.println("ETH Connected"); addLog("ETH Connected"); break; case ARDUINO_EVENT_ETH_GOT_IP: Serial.print("ETH IP: "); Serial.println(ETH.localIP()); ethConnected = true; addLog(String("ETH GOT IP ") + ETH.localIP().toString()); startOTA(); break; case ARDUINO_EVENT_ETH_DISCONNECTED: Serial.println("ETH Disconnected"); ethConnected = false; addLog("ETH Disconnected"); break; case ARDUINO_EVENT_ETH_STOP: Serial.println("ETH Stopped"); ethConnected = false; addLog("ETH Stopped"); break; default: break; } } /* ============================================================ SETUP / LOOP ============================================================ */ void setup() { Serial.begin(115200); delay(1000); Serial.println(); Serial.println("ESP32 ETH01 Betar RS485 + MIR BLE + REST + OTA auto"); addLog("BOOT"); #if DE_RE_PIN >= 0 pinMode(DE_RE_PIN, OUTPUT); digitalWrite(DE_RE_PIN, LOW); #endif RS485.begin(9600, SERIAL_8N1, RS485_RX_PIN, RS485_TX_PIN); NimBLEDevice::init(""); NimBLEDevice::setPower(ESP_PWR_LVL_P9); Network.onEvent(onEvent); ETH.begin( ETH_PHY_TYPE, ETH_PHY_ADDR, ETH_PHY_MDC, ETH_PHY_MDIO, ETH_PHY_POWER, ETH_CLK_MODE ); if (!ETH.config(localIP, gateway, subnet, dns1)) { Serial.println("ETH static IP config failed"); addLog("ETH static IP config failed"); } server.on("/", handleRoot); server.on("/api", handleApi); server.on("/json", handleApi); server.on("/poll", handlePollBetar); server.on("/poll_betar", handlePollBetar); server.on("/log", handleLog); server.begin(); Serial.println("HTTP server started"); Serial.println("Open: http://192.168.1.60/api"); addLog("HTTP server started"); pollBetarMeter(); unsigned long now = millis(); nextBetarPollMs = now + betarPollInterval; nextMirPollMs = now + 30000; } void loop() { if (otaStarted) { ArduinoOTA.handle(); } server.handleClient(); unsigned long now = millis(); if ((long)(now - nextBetarPollMs) >= 0) { pollBetarMeter(); nextBetarPollMs = millis() + betarPollInterval; } now = millis(); if ((long)(now - nextMirPollMs) >= 0) { pollMirMeter(); nextMirPollMs = millis() + mirPollInterval; } }
Итоговая прошивка заняла в памяти устройства 959 килобайт.
Главный технический вывод
Самая важная находка состояла в том, что для Бетара недостаточно просто отправлять основной запрос. Формально протокол простой, но в реальной линии обмен оказался чувствителен к начальному состоянию приёма. Добавление предварительного адресного запроса стабилизировало основной кадр.
Для МИР главная находка была другой: его BLE-интерфейс лучше воспринимать не как API с фиксированными регистрами, а как удалённое листание страниц. Поэтому код должен искать нужные значения по содержимому ответов, а не полагаться на то, что нужный тариф всегда окажется на одной и той же позиции.
Именно эти два изменения — warmup перед RS-485-запросом Бетара и сканирование страниц до результата для МИР — превратили нестабильную экспериментальную сборку в рабочий автономный считыватель.
Ну и отдельно хотелось бы коснуться электрической реализации такого зоопарка устройств. Проблема в том, что для ESP32 нужно 5 вольт DC, а для RS-485 интерфейса от 9 до 24 вольт. Поэтому решено было взять 5 вольт от блока питания коммутатора и с помощью DC-DC повышайки преобразовать их в 12 вольт.

На картинке это всё красиво, но на момент отладки выглядело всё не так :)

Поэтому, чтобы спрятать такой зоопарк плат в щитке подъезда приобрёл корпус на DIN-рейку

И к нему приделал 8-контактный клеммник




Итоговый вариант получается такой:

Подводя итог хочется сказать, что такая реализация для квартиры в многоквартирном доме - нецелесообразна. Поэтому больше рассматриваю этот проект, как источник получения опыта. Который кому-нибудь может пригодиться в более сложных инженерных системах.
Комментарии (21)

select26
11.05.2026 08:36Тема интересная, но читать статью просто невозмжно из за простыней кода. Автор, сверните код пожалуйста?
Второй вопрос про "warmup": думаю тут или ваш косяк, или косяк счетчика. Если вы воспроизводили одинаковое поведение на ESP и Modbus gateway, при "Обмен идёт на скорости: 9600 baud, 8N1.", то, скорее всего, это проблема счетчика. Обращались ли вы в техподдержку производителя? Возможно они выпустят исправление.
Maikl747 Автор
11.05.2026 08:36Прошу прощения. Я что-то не догадался убрать код под спойлер... Ситуацию исправил.
Насчёт warmup я не связывался с производителем, так как мне проще в данной ситуации преодолеть преграду, чем воевать с её последствиями.
Но у меня есть аналогичный опыт с электросчётчиком Меркурий 230. Там также команды, указанные в паспорте, не работают нормально. Поэтому я запускал сниффинг штатной программы "Конфигуратор" и выяснил, что помимо команд тоже идут прогревы и задержки между командами. По этому вопросу я связался с производителем, но вразумительных ответов не получил, поэтому в случае с Бетар - не стал тратить время, учитывая прошлый опыт с Меркурием.

CyrK
11.05.2026 08:36А для чего всё это?

Poletavatti
11.05.2026 08:36В частном доме это полезно: можно прикидывать эффективность отопления и утепления, а также знать сколько воды запасать

triada63
11.05.2026 08:36Ну чтобы забыть про подачу показаний счетчика. А так как показания подаются всего 3 дня в месяце это довольно-таки напряжный процесс

VT100
11.05.2026 08:36Судя по картинке с “красным” конвертером и
У используемого MAX3485-модуля автонаправление. DE/RE не используется.
в коде - проблема с Бетаром может быть в этом. И не понял по 12 В, это Бетару надо или конвертеру?

Maikl747 Автор
11.05.2026 08:3612В Бетару нужно для питания RS-485 интерфейса. А так Бетар работает на своей батарейке 6-10лет.
Poletavatti
А почему не рассматривался водосчётчик с фотоэлементом (без мозгов)? Они дешевле намного и к ним не нужно пытаться подобрать секретный способ стабильного взаимодействия
Maikl747 Автор
Про этот вариант подумал в первую очередь. Но отказался от него, так как он не передаёт показания, а лишь передаёт импульсы. Таким образом - если пропала электроэнергия, а счётчик прокрутился - уже не знаешь, какие там показания. Поэтому решил переплатить за электронный вариант.
RomanKu
Есть батарейные ZigBee счётчики, которые, по заявлениям разработчика, живут на одном комплекте батарей около 20 лет.
До этого у меня была первая версия и к ней были вопросы, но полгода работали на одном комплекте, потом ещё пару недель данные не передавались, но напряжения хватало для работы самого счётчика (после замены батареек начинали передаваться актуальные данные), сейчас взял новую версию и на воду и на газ -- впечатления только положительные, а погрешность минимальная.
В целом, с учётом срока службы водяных счётчиков 6 лет (а их вообще кто-то поверяет?) поиск именно rs485 версии ещё и свежей не то, чтобы, целесообразен.
Maikl747 Автор
А что за счётчики ZigBee? Какая модель?
RomanKu
Вот описание на github https://github.com/DIYZi/ZTU_WM_V2/wiki
Maikl747 Автор
Интересное устройство... А где можно готовую версию купить?
RomanKu
Там по ссылке на гитхаб внизу есть ссылки на ТГ каналы с покупкой/продажей устройств и на самого автора, я брал у него. Цена в районе 2000р + пересылка. Прямые ссылки не публикую, чтобы не заподозрили в рекламе, но там github автора есть репозиторий market с актуальными zigbee устройствами и ценами
Ну и в качестве оффтопа, недавно поставил датчик давления воды от EFECTA
Там батарейное питание в качестве резервного используется, а основное USB - тут минус, конечно, но у меня запитывается от системного ИБП. Использую для информирования о пропаже и появлении воды + для перехода на резерв и обратно
Poletavatti
Использованный вами счётчик работает точно так же, но батарейка встроена
К ESP батарейку тоже можно прицепить
Maikl747 Автор
И именно встроенная батарея, рассчитанная на 6-10 лет, обеспечивает всегда стабильные показания. К ESP можно прикрутить батарейку, но энергопотребление... Если верить расчётам энергопотребления с помощью нейронки, то код ESP32 непрерывно работает в основном цикле loop() без режимов deep sleep или light sleep, опрашивая счётчик БЕТАР по RS-485 каждые 60 с и MIR по BLE каждые 3 мин (180 с), плюс Ethernet, HTTP-сервер и OTA. Среднее энергопотребление составит 50–150 мА при 3.3 В (~165–500 мВт), в зависимости от активности Ethernet и WiFi/BLE-стека. А значит на батарее 3000 мАч·ч проработает ~1 день.
Poletavatti
Рукалицо
triada63
А что не так? Как раз нейронка такие простые вычисления делает очень хорошо. Или же кроме хейта не будет конструктивного ответа?
xSVPx
Вы серьезно :)? Вы хоть сами прочитайте что она вам там насчитала...
Люди не пользующиеся чатботами делают погодные станции с солнечной батареей и ионистором. Заряда хватает до утра. И его там совершенно не 10втч :)))
RTFM13
у есп есть экономичное ядро с прерываниями которое вполне справится со счетом импульсов, остальное отключается и включается после восстановления подачи эелектричества.
но я бы поставил второй контроллер + 2032 которой в таком режиме хватит на многие годы.
Guestishe
Это нейронка посчитала для вашего решения. Для чистого счета импульсов дипсик выдал вердикт "от года" на 1000mah.