Снимаем показания счётчика воды «Бетар» по RS-485 и «МИР» по BLE с помощью ESP32

Страницы:  1

Ответить
 

Professor Seleznov


В предыдущей статье 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-портом.
pic
При всех плюсах такого решения есть и минус - нет type-c/microUSV и кнопок. Поэтому шить приходится через TTL и переводить в режим прошивки замыканием пина IOO на GND.
К счастью у него оказался вагон встроенной памяти в размере 8 мегабайт, поэтому для моей задачи вполне хватит. И можно даже ещё нагрузить...
pic
Для ускорения написания статьи картинку нарисовал с помощью нейронки
Однако при адаптации кода от малинки к 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;
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 < 0x10) s += "0";
s += String(data, 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;
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;
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++;
if (i >= (int)text.length()) break;
int start = i;
bool dotSeen = false;
while (i < (int)text.length()) {
char c = text;
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) &&
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) &&
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) &&
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
pic
СГВ-Э15
Но найти счётчик в интернете - это ещё пол дела... Его ещё надо купить. И тут тоже возникла проблема. Потому что на маркетплейсах такой счётчик не продают (видимо цена кусачая 2980 рублей), поэтому я обратился в официальный магазин Бетар в своём городе. Там мне сообщили, что позиция заказная и привезут мне её через три недели. Поэтому взяли 100% предоплату и отправили ждать... Кстати из любопытства я у них спросил, сколько они таких счётчиков продают. И мне ответили, что несколько штук в год.
Через три недели приехал мой счётчик и я вызвал сантехника управляющей компании для установки (так как счётчик пломбируется УК). Пожилой сантехник УК приехал по заявке и наотрез отказался устанавливать этот счётчик с мотивировкой "такие счётчики не для квартир, а только для коттеджей" . Пришлось проводить ликбез через инженера УК.
Так как ESP32 не имеет прямой связи с шиной RS-485, то для такой связи я приобрёл адаптер MAX3485.
pic
MAX3485
Он отличается от MAX485 тем, что работает с логикой 3,3 вольта, а не 5 вольт. А это родное напряжение для ESP32 и при такой схеме не требуется возни с резисторами или логическим преобразователем уровня.
Схема подключения получается следующей:
pic
Параллельно, ещё перед установкой, я запросил у производителя по электронной почте описание протокола 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 115A 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;
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 < 0x10) s += "0";
s += String(data, 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;
}
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 & 0x0F;
int hi = (b >> 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;
}
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.
pic
Для ускорения написания статьи картинку рисовал с помощью нейронки
На практике самым сложным оказался не сам 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;
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;
}
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 & 0x0F;
int hi = (b >> 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;
}
return value / 1000.0;
}
String bytesToHex(const uint8_t *data, int len) {
String s;
for (int i = 0; i < len; i++) {
if (data < 0x10) s += "0";
s += String(data, 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;
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;
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++;
if (i >= (int)text.length()) break;
int start = i;
bool dotSeen = false;
while (i < (int)text.length()) {
char c = text;
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) &&
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) &&
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) &&
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 вольт.
pic
Картинку по быстрому сваял с помощью нейронки
На картинке это всё красиво, но на момент отладки выглядело всё не так
pic
Поэтому, чтобы спрятать такой зоопарк плат в щитке подъезда приобрёл корпус на DIN-рейку
pic
И к нему приделал 8-контактный клеммник
pic
pic
pic
pic
Итоговый вариант получается такой:
pic
Подводя итог хочется сказать, что такая реализация для квартиры в многоквартирном доме - нецелесообразна. Поэтому больше рассматриваю этот проект, как источник получения опыта. Который кому-нибудь может пригодиться в более сложных инженерных системах.-Источник
 
Loading...
Error