В предыдущей статье 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-контактный клеммник




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

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

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