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

При всех плюсах такого решения есть и минус - нет type-c/microUSV и кнопок. Поэтому шить приходится через TTL и переводить в режим прошивки замыканием пина IOO на GND.
При всех плюсах такого решения есть и минус - нет type-c/microUSV и кнопок. Поэтому шить приходится через TTL и переводить в режим прошивки замыканием пина IOO на GND.

К счастью у него оказался вагон встроенной памяти в размере 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 были получены.

Решение было изменить логику с “фиксированной последовательности” на “сканирование до результата”:

  1. Отправить команду входа в раздел энергии.

  2. Читать текущую страницу.

  3. Отправлять NEXT до тех пор, пока не будут найдены total, T1 и T2.

  4. Ограничить количество шагов, чтобы не зависнуть.

  5. После этого перейти к текущим параметрам и аналогично найти дату, время, ток и напряжение.

В итоговом коде МИР-опрос стал результатно-ориентированным: я больше не надееюсь, что 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

СГВ-Э15
СГВ-Э15

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

Через три недели приехал мой счётчик и я вызвал сантехника управляющей компании для установки (так как счётчик пломбируется УК). Пожилой сантехник УК приехал по заявке и наотрез отказался устанавливать этот счётчик с мотивировкой "такие счётчики не для квартир, а только для коттеджей" :) . Пришлось проводить ликбез через инженера УК.

Так как ESP32 не имеет прямой связи с шиной RS-485, то для такой связи я приобрёл адаптер MAX3485.

MAX3485
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 или с искажением начала. Затем я проверил другую последовательность:

  1. CD 00 00 00 00 96 96 запрос адреса

  2. короткая пауза

  3. CD 03 D1 52 EB 71 82 запрос основных данных

И эта схема дала стабильный результат: после адресного запроса основной 19-байтный ответ стал приходить корректно. Я назвал адресный запрос “прогревом” линии. По сути, это не получение данных ради адреса, а подготовительный обмен, после которого основной запрос Бетара стал воспроизводимым. В код ESP32 это было перенесено так:

  1. Очистить входной UART-буфер.

  2. Отправить CD 00 00 00 00 96 96.

  3. Прочитать и отбросить ответ B5 03 D1 52 EB 11.

  4. Подождать около 500 мс.

  5. Снова очистить UART-буфер.

  6. Отправить основной запрос CD 03 D1 52 EB 71 82.

  7. Искать внутри полученного буфера корректный 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[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 делает следующее:

  1. Поднимает Ethernet со статическим IP 192.168.1.60.

  2. Запускает HTTP API и OTA.

  3. Опрос Бетар:

  • адресный 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-контактный клеммник

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

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