Задача разработки — быстрая проверка прибора учёта электроэнергии в полевых условиях. Устройство должно обладать низкой стоимостью, высокой мобильностью и более простым интерфейсом в сравнении с аналогом — Энергомонитор-3.3 Т1.

схема работы проекта
схема работы проекта

В целом, ничего принципиально нового, это очередной велосипед из ESP и PZEM. В статье я собрал разные, как мне показалось, неочевидные для новичков моменты. Заранее отмечу, что не являюсь профессиональным программистом микроконтроллеров или фронтендером. Я простой инженер, поэтому в статье будет очень много ссылок, которые мне очень помогли.

1. IDE

Выбор IDE

Писать код для ESP можно хоть в блокноте, лично мне удобнее пользоваться Visual Studio Code. Для этой среды достаточно расширений для программирования на ESP (к примеру - PlatformIO). Espressif IDE для Visual Studio Code существует также в виде самостоятельной IDE (на самом деле она на базе EclipseCDT). (Подробнее - Как и на чём программировать ESP32 и ESP8266, Переползаем с Arduino IDE на VSCode + PlatformIO, ESP32 в окружении VSCode).

Мы остановимся на Arduino IDE, т. к. она лучше всего подходит для новичков.

2. Аппаратная часть

ESP8266 NodeMCU V3 - плата на базе wi-fi модуля ESP8266 и USB-UART на CH340, как основа проекта.
3 х PZEM-004T V3.0 - Модуль для замера напряжения, тока, частоты, мощности и суммарно потребленной электроэнергии в кВт*ч.
KY-018 - фоторезистор для считывания импульсов с прибора учёта электроэнергии.

принципиальная электрическая схема проекта
принципиальная электрическая схема проекта

3. Настройка Arduino IDE

Для работы системы необходимо подключить по инструкции библиотеки:

3.1. https://github.com/mandulaj/PZEM-004T-v30 (в Arduino IDE: инструменты->управление библиотеками - "PZEM-004T-v30").
3.2. https://github.com/esp8266/Arduino (в Arduino IDE: Инструменты->Плата->Менеджер плат - "esp8266 by ESP8266").
3.3. https://github.com/bblanchon/ArduinoJson (в Arduino IDE: Инструменты->Управление библиотеками - "ArduinoJson") или https://arduinojson.org/?utm_source=meta&utm_medium=library.properties.

Необходимо также подключить по инструкции дополнительные ссылки для Менеджера плат для NodeMCU: http://arduino.esp8266.com/stable/package_esp8266com_index.json (в Arduino IDE: Файл->Параметры->дополнительные ссылки для Менеджера – вставить ссылку в поле ввода).

4. Программная часть

Общая архитектура
Общая архитектура

4.1 Wi-fi

Для начала необходимо выбрать режим, в котором будет работать ESP: точка доступа WiFi.mode(WIFI_AP) когда вы подключаетесь к ESP, WiFi.mode(WIFI_STA), когда ESP подключается к вашей точке доступа (роутеру) или совместная работа этих режимов - WiFi.mode(WIFI_AP_STA). Подробнее - ESP32 Useful Wi-Fi Library Functions (Arduino IDE).

Я для наглядности реализовал такую логику: пробуем подключиться к Wi-fi, если не удаётся, то создаём свою точку доступа.

настройка Wi-fi в .ino
#include <ESP8266WiFi.h>
#include <ESP8266WiFiMulti.h>
#include <ESP8266WebServer.h>
#include <WiFiUdp.h>
#include <WiFiClient.h>

#define APSSID "SmartGridComMeterESPap" // Имя точки доступа, которую создаст ESP
#define STASSID "Admin"            // Точка доступа (логин и пароль от wifi), к которой подключится ESP
#define STAPSK "Admin" 
#define STASSID2 "admin"
#define STAPSK2 "admin" 

const char *ap_ssid = APSSID;
const char* ssid = STASSID;
const char* password = STAPSK;
const char* ssid2 = STASSID2;
const char* password2 = STAPSK2;

ESP8266WiFiMulti wifiMulti;
ESP8266WebServer server(80);

void setup() {
  Serial.begin(115200);

  WiFi.mode(WIFI_OFF); // Предотвращает проблемы с повторным подключением (слишком долгое подключение)
  delay(500);

  /*раздел подключения к Wi-Fi*/
  WiFi.mode(WIFI_STA);
  wifiMulti.addAP(ssid, password);
  wifiMulti.addAP(ssid2, password2);
  Serial.println("");
  Serial.print("Connecting");
  // Ожидаем подключения в течении 5 секунд
  unsigned long connectionTimer = millis() + 5000;
  while (millis() < connectionTimer && wifiMulti.run() != WL_CONNECTED) { 
    if (wifiMulti.run() != WL_CONNECTED) {
      break;
    }
    delay(500);
    Serial.print(".");
  }
  //  Если подключение успешно, отображаем IP-адрес в последовательном мониторе
  if (wifiMulti.run() == WL_CONNECTED) {
    Serial.println(""); 
    Serial.print("Connected to Network/SSID: ");
    Serial.println(WiFi.SSID());
    Serial.print("IP address: "); //http://192.168.31.146/
    Serial.println(WiFi.localIP());  // IP-адрес, назначенный ESP
  } else { //если подключения нет, создаём свою точку доступа
    // раздел добавления точки доступа wifi
    WiFi.mode(WIFI_AP);
    Serial.println("Configuring access point...");
    WiFi.softAP(ap_ssid);                     //Запуск AccessPoint с указанными учетными данными
    Serial.print("Access Point Name: "); 
    Serial.println(ap_ssid);
    IPAddress myIP = WiFi.softAPIP();          //IP-адрес нашей точки доступа Esp8266 (где мы можем размещать веб-страницы и просматривать данные)
    Serial.print("Access Point IP address: ");
    Serial.println(myIP); // http://192.168.4.1/
    Serial.println("");
  }

}

Вообще, правильным решением будет подключить нормальный WiFiManager, который позволит пользователю настраивать сеть самому, к тому же обеспечит более стабильное подключение.

4.2. Настройка HTTP-сервера

Добавляем в void setup() следующие строчки:

  server.on("/", handleRoot);
  server.onNotFound(handle_NotFound);
  server.begin();
  Serial.println("HTTP server started");

Это необходимый минимум в setup() для отображения нашей HTML страницы в браузере.

handleRoot() и handle_NotFound() необходимо реализовать в виде функций, например так:

void handleRoot() {
 String html_index_h = webpage; //для обновления HTML/css/js в строку "webpage" в "index.h" запустите "front/htmlToH.exe"
 server.send(200, "text/html", html_index_h);
}

void handle_NotFound() {
    server.send(404, "text/plain", "Not found");
}
void loop() {
  /*// если необходимо контролировать wifi подключение, например по светодиоду
  while (wifiMulti.run() != WL_CONNECTED) {
    Serial.print(".");
  }*/
  server.handleClient();
}

Если с handle_NotFound() всё более менее понятно (отправляем на фронт 404-ю ошибку в случае проблем с сервером. т.е. с ESP), то что такое «webpage»? Это наша HTML-страничка в формате строки:

const char webpage[] PROGMEM = R"=====(
<!DOCTYPE html>
<html>
  …
</html>)=====";

Внутри круглых скобок хранится код на языке HTML разметки. Обычно скрипты и стили также должны быть встроены в HTML:

const char webpage[] PROGMEM = R"=====(
<!DOCTYPE html>
<html>
    <head>
  <style type="text/css">
  …
  </style>
      </head>
      <script>
      …
      </script>
  …
</html>)=====";

4.3. Пишем фронтенд

такой вот незаурядный дизайн
такой вот незаурядный дизайн

Разработку фронта для проекта лучше выстроить так:

[1] пишем отдельно файлы index.html, script.js и style.css

[2] стили и скрипты подключаем к html классически:

<link rel="stylesheet" href="style.css">
<script src="script.js"></script>

[3] Перед тем как заливать прошивку в ESP запускаем скрипт, который преобразует наши index.html, script.js и style.css в один файл index.h:

htmlToH.cpp:
#include <string>
#include <iostream>
#include <fstream>
#include <filesystem>
#include <stdlib.h>

std::string WebToStr(std::ifstream& index_html_in) {
    std::string result;
    std::string line;
    if (index_html_in.is_open()) {
        while (std::getline(index_html_in, line)) {
            char last_line_element = line[line.size() - 1];
            if (last_line_element == ';' || last_line_element == '>' || last_line_element == '{' || last_line_element == '}') {
                result +="\n";
            }
            if (line.find("<link rel=\"stylesheet\"") != -1) {
                line.clear();
                line = "<style type=\"text/css\">";
                std::ifstream index_html_in("style.css");
                line += WebToStr(index_html_in);
                line += "\n</style>";
            }
            if (line.find("<script src=") != -1) {
                line.clear();
                line = "<script>";
                std::ifstream index_html_in("script.js");
                line += WebToStr(index_html_in);
                line += "\n</script>";
            }
            result += line;
        }
    }
    return result;
}

std::string MakeStrFromWeb() {
    std::ifstream index_html_in("index.html"); // открываем файл для чтения
    std::string html = "const char webpage[] PROGMEM = R\"=====(";
    html += WebToStr(index_html_in);
    html += ")=====\";";
    const std::filesystem::path CurrentPath = std::filesystem::current_path().parent_path();
    std::ofstream index_h_out(CurrentPath/ "srs/PZEM_nodemcu_three_phase/index.h"); // открываем файл для записи
    if (index_h_out.is_open()) {
        index_h_out << html;
    }
    index_h_out.close();
    return html;
}

int main() {
    std::string html = MakeStrFromWeb();
    return 0;
}

Не забываем подключить наш index.h к основному проекту:

#include "index.h" //тут хранится наш webpage

void handleRoot() {
 String html_index_h = webpage; //для обновления HTML/css/js в строку "webpage" в "index.h" запустите "front/htmlToH.exe"
 server.send(200, "text/html", html_index_h);
}

Всю эту процедуру можно автоматизировать, т.е. выполнять обновление index.h при каждой заливке прошивки или при любом изменении index.html, script.js и style.css (я в своём проекте пока этим заморачивался). MakeStrFromWeb() можно написать на javascript, что будет более логичным в контексте фронт разработки.
Можно также загрузить index.html, script.js и style.css в ESP с помощью файловой системы SPIFFS. Подробнее - ESP8266 Web Server using SPIFFS (SPI Flash File System) – NodeMCU
Но расширения, к сожалению не поддерживаются Arduino IDE 2.0.x, только версиями 1.x. Но если хотите, можете скачать старую IDE и заморочиться с SPIFFS (я пробовал – работает). К тому же ARDUINO 1.8.18 работает весьма неплохо.

4.4 Измерения с помощью PZEM

Тут хитростей нет – если вы правильно подключили сигналы RX/TX от PZEM к ESP, подали напряжения (обязательно, т.к. по цепям напряжения PZEM и дополнительное питание, а без него напряжение будет NaN вместо 0 В) и подключили библиотеку PZEM004Tv30 то значения в мониторе порта (см. пример) вы получите без проблем:

setValues.h:
#include <PZEM004Tv30.h>

PZEM004Tv30 pzem1(D1, D2); // (RX,TX) подключиться к TX,RX PZEM1
PZEM004Tv30 pzem2(D5, D6); // (RX,TX) подключиться к TX,RX PZEM2
PZEM004Tv30 pzem3(D7, D0); // (RX,TX) подключиться к TX,RX PZEM3

  float current = 0; // суммарный ток
  float power = 0;   // суммарная мощность
  float energy = 0;  // суммарная энергия

  float voltage1 = 0;
  float current1= 0;
  float power1= 0;
  float energy1= 0;
  float frequency1= 0;
  float pf1= 0;

  float voltage2 = 0;
  float current2= 0;
  float power2= 0;
  float energy2= 0;
  float frequency2= 0;
  float pf2= 0;

  float voltage3 =0;
  float current3= 0;
  float power3= 0;
  float energy3= 0;
  float frequency3= 0;
  float pf3= 0;

void SetPzem1Values() {
  voltage1 = 0;
  current1= 0;
  power1= 0;
  energy1= 0;
  frequency1= 0;
  pf1= 0;
  if (!isnan(voltage1 = pzem1.voltage())) {
    current1 = pzem1.current() * currentTransformerTransformationRatio;
    current += current1;
    frequency1 = pzem1.frequency();
    pf1 = pzem1.pf();
    power1 = pzem1.power() / WtTokWtScale * currentTransformerTransformationRatio;
    power += power1;
    energy1 = pzem1.energy() * currentTransformerTransformationRatio;
    energy += energy1;
  }
}

void SetPzem2Values() {
  voltage2 = 0;
  current2= 0;
  power2= 0;
  energy2= 0;
  frequency2= 0;
  pf2= 0;
  if (!isnan(voltage2 = pzem2.voltage())) {
    current2 = pzem2.current() * currentTransformerTransformationRatio;
    current += current2;
    frequency2 = pzem2.frequency();
    pf2 = pzem2.pf();
    power2 = pzem2.power() / WtTokWtScale * currentTransformerTransformationRatio;
    power += power2;
    energy2 = pzem2.energy() * currentTransformerTransformationRatio;
    energy += energy2;
  }
}

void SetPzem3Values() {
  voltage3 =0;
  current3= 0;
  power3= 0;
  energy3= 0;
  frequency3= 0;
  pf3= 0;
  if (!isnan(voltage3 = pzem3.voltage())) {
    current3 = pzem3.current() * currentTransformerTransformationRatio;
    current += current3;
    frequency3 = pzem3.frequency();
    pf3 = pzem3.pf();
    power3 = pzem3.power() / WtTokWtScale * currentTransformerTransformationRatio;
    power += power3;
    energy3 = pzem3.energy() * currentTransformerTransformationRatio;
    energy += energy3;
  }
}

void resetCurrentValues() {
  yield();
  current = 0;
  power = 0;
  energy = 0;
  queueSum = 0;
  while (!meterBlinkPeriods.empty()) meterBlinkPeriods.pop();
  queueSize = 1;
  KYimpNumSumm = 0;
  winHi = 0, winLo = 1024;
  initWindow();
  meterWattage = 0;
  constMeterImpsNum = 1000;
  yield();
}

Если у вас есть участки программы, которые долго выполняются, то нужно разместить вызовы yield() до и после тяжёлых блоков кода. Также в чужих скетчах можно встретить delay(0), по сути, это и есть yield().

У нас задача отправить это в веб-браузер без обновлений html страницы, как это сделано например в этом проекте с помощью тега <meta http-equiv=refresh content=30>.

4.5 Отправка данных PZEM c ESP на фронт в формате JSON

Тут нам поможет AJAX – GET и POST запросы. Подробнее - Веб-сервер AJAX на ESP8266: динамическое обновление веб-страниц без их перезагрузки.
Проблема проекта выше только в том, что там разработчик отправляет только пару сигналов управления светодиодами в виде текcта, а у нас очень много данных. Отправлять их в формате строки и парсить на стороне браузера клиента весьма накладно. Поэтому воспользуемся JSON.

Всё по порядку:
- п.1. Добавляем функцию отправки на наш сервер:
В setup() после строчки server.on("/", handleRoot) пишем: server.on("/pzem_values", SendPzemsValues).

- п.2. Теперь реализуем функцию SendPzemsValues():

#include <ArduinoJson.h>

void SendPzemsValues() {
  yield();
  current = 0;
  power = 0;
  energy = 0;
  SetPzem1Values();
  SetPzem2Values();
  SetPzem3Values();

  // отправляем ответ в формате json
  JsonDocument doc; // создаём JSON документ
  // Добавить массивы в JSON документ
  JsonArray data = doc["voltages"].to<JsonArray>();
    data.add(voltage1);
    data.add(voltage2);
    data.add(voltage3);
  data = doc["currents"].to<JsonArray>();
    data.add(current1);
    data.add(current2);
    data.add(current3);
  data = doc["powers"].to<JsonArray>();
    data.add(power1);
    data.add(power2);
    data.add(power3);
  data = doc["energies"].to<JsonArray>();
    data.add(energy1);
    data.add(energy2);
    data.add(energy3);
  data = doc["frequencies"].to<JsonArray>();
    data.add(frequency1);
    data.add(frequency2);
    data.add(frequency3);
  data = doc["powerFactories"].to<JsonArray>();
    data.add(pf1);
    data.add(pf2);
    data.add(pf3);
  // Добавить объекты в JSON документ
  JsonObject FullValues =  doc["FullValues"].to<JsonObject>();
    FullValues["current"] = current;
    FullValues["power"] = power;
    FullValues["energy"] = energy;
  server.send(200, "application/json", doc.as<String>());
  yield();
}

- п.3. Структура json:

{
	"voltages": [
		"voltage1",
		"voltage2",
		"voltage3"
    ],
	"currents": [
		"current1",
		"current2",
		"current3"
    ],
	"powers": [
		"power1",
		"power2",
		"power3"
    ],
	"energies": [
		"energy1",
		"energy2",
		"energy3"
    ],
	"frequencies": [
		"frequency1",
		"frequency2",
		"frequency3"
    ],
	"powerFactories": [
		"pf1",
		"pf2",
		"pf3"
    ],
	"FullValues": {
		"current": current,
		"power": power,
		"energy": energy
    } 
}

4.6 Запрос данных PZEM с ESP на фронт в формате JSON

Делаем всё согласно инструкции:

function getPZEMsData() {
    var xhttp = new XMLHttpRequest();
    xhttp.open("GET", "pzem_values", true);
    xhttp.responseType = "json";
    xhttp.send();
    xhttp.onreadystatechange = function() {
      if (this.readyState == 4 && this.status == 200) {
          console.log("getPZEMsData successful✔️\n\r");
      }
    };
    xhttp.onload = function () {
        ViewAllESPdata(xhttp.response);
    };
};

функция ViewAllESPdata(ESPdata) получает JSON, парсит его и выводит в веб-интерфейс с заданным кол-вом точек после запятой. Также она запускает функцию записи всех данных в массив для последующего сохранения в csv (подробности см. в репозитории проекта)
Обратите внимание на строчку 3:
второй аргумент в методе open -"pzem_values" должен совпадать с первым аргументом метода server.on("/pzem_values", SendPzemsValues) в void setup(), где мы назначаем функцию отправки данных на фронт с ESP.

На фронте для периодического опроса ESP достаточно повесить на кнопку «старт»:

let PZEMinterval;
let ESPsurveyPeriod = 1000; // период опроса ESP в мс

let StartMeterCheckBtn = document.getElementById('StartMeterCheck');
StartMeterCheckBtn.addEventListener('click', startMeterCheck);

function startMeterCheck(e) {
  …
  PZEMinterval = setInterval(getPZEMsData, ESPsurveyPeriod);
  …
}

Остановить опрос (к примеру, по кнопке «стоп») очень просто: clearInterval(PZEMinterval).

4.7 Отправка данных или команд с фронта на ESP

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

Воспользуемся методом POST:

function sendCurrentTransformerTransformationRatio() {
    if (CheckCurrentTransformerTransformationRatioInputs()) {
        var xhttp = new XMLHttpRequest();
        xhttp.open("POST",
            "current_transformer_transformation_ratio?currentTransformerTransformationRatio="+currentTransformerTransformationRatioCheck.value,
            true);
        xhttp.setRequestHeader("Content-Type", "text; charset=UTF-8");
        xhttp.send();
        xhttp.onreadystatechange = function() {
            if (this.readyState == 4 && this.status == 200) {
                console.log("sendCurrentTransformerTransformationRatio successful✔️\n\r");
                console.log(this.responseText);
            }
        };
    }
};

Обратите внимание, что теперь отправляем «text» вместо «json».
Второй аргумент в методе open состоит из 3х частей:
«current_transformer_transformation_ratio» - должен совпадать с первым аргуметом в строчке

server.on("/current_transformer_transformation_ratio", SetCurrentTransformerTransformationRatio)

которую мы добавим в void setup() в ESP так же как сделали получение данных PZEM с ESP
«currentTransformerTransformationRatio» - название переменной, которой мы будем пользоваться на ESP для того, чтобы получить Ктт.
Далее прибавляем к этой стоке переменную, которая хранит Ктт и которую пользователь задал на интерфейсе.

4.8 Получение данных с фронта на ESP

  // Настройка HTTP-сервера
  server.on("/", handleRoot);
  server.on("/current_transformer_transformation_ratio", SetCurrentTransformerTransformationRatio);
  server.on("/pzem_values", SendPzemsValues);
  server.on("/reset", Reset);
  server.onNotFound(handle_NotFound);
  server.begin();
  Serial.println("HTTP server started");

Теперь необходимо написать функцию SetCurrentTransformerTransformationRatio(), которая будет устанавливать Ктт на ESP:

void SetCurrentTransformerTransformationRatio() {
  String CurrentTransformerTransformationRatioStr = server.arg("currentTransformerTransformationRatio");
  currentTransformerTransformationRatio = CurrentTransformerTransformationRatioStr.toInt();
  server.send(200, "text/plane", "currentTransformerTransformationRatio has been set");
}

Тут мы как раз используем вторую и третью часть второго аргумента метода open, который мы задавали на фронте в script.js, для того, чтобы вытащить Ктт.
Примерно также мы будем сбрасывать PZEMы с помощью функции Reset() в script.js:

function Reset() {
    var xhttp = new XMLHttpRequest();
    xhttp.open("GET", "reset", true);
    xhttp.responseType = "text";
    xhttp.send();
    xhttp.onload = function () {
        console.log(this.responseText);
    };
};

Только в данном случае мы не отправляем никаких данных на ESP:

void Reset() {
  resetCurrentValues();
  currentTransformerTransformationRatio = 1;
  if (pzem1.resetEnergy() &&
      pzem2.resetEnergy() &&
      pzem3.resetEnergy()) {
        server.send(200, "text/plane", "Energy in pzems has been reset");
  } else {
    server.send(200, "text/plane", "power reset error!");
  }
}

4.9 Считывание импульсов с прибора учёта с помощью фоторезистора

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

Алгоритм считывания импульсов моргания светодиода прибора учёта давно придуман за нас и подробно описан - Подключаем ардуино к счётчику.
С незначительными изменениями он был перенесён в проект (см meterBlinkPeriodCalc.h). Добавлена возможность рассчитывать погрешность на основе нескольких подряд возникающих импульсов, как это реализовано в Энергомонитор-3.3 Т1. Длину очереди импульсов size_t queueSize (основная логика крутится вокруг FIFO std::queue<double> meterBlinkPeriods) можно задавать из веб-интерфейса.

void loop() {
  server.handleClient();
  delay(10);
  checkLedState();
}

Всё это прекрасно работает до тех пор, пока мы не начинаем опрашивать ESP. Начинаются пропуски импульсов и расчёт погрешности становится некорректным.
Всё потому, что на ESP возникает несколько задач, которые он не может решать параллельно. На этом этапе нам следует воспользоваться ОСРВ например ESP8266_RTOS_SDK.

Многозадачность в Arduino

Но мы попробуем обойтись малой кровью. Необходимо выполнять checkLedState() за пределами void loop(), что мы и сделаем вместо того, чтобы настраивать millis().

Внимание! Ненормальное программирование, так писать не следует, повторять на свой страх и риск:
void SendPzemsValues() {
  yield();    checkLedState();// костыльно решаем проблему многозадачности
  current = 0;    checkLedState();
  power = 0;
  energy = 0;    checkLedState();
  SetPzem1Values();    checkLedState();
  SetPzem2Values();    checkLedState();
  SetPzem3Values();    checkLedState();

  // отправляем ответ в формате json
  JsonDocument doc;    checkLedState(); // создаём JSON документ
  // Добавить массивы в JSON документ
  JsonArray data = doc["voltages"].to<JsonArray>();    checkLedState();
    data.add(voltage1);    checkLedState();
    data.add(voltage2);    checkLedState();
    data.add(voltage3);    checkLedState();
  data = doc["currents"].to<JsonArray>();    checkLedState();
    data.add(current1);    checkLedState();
    data.add(current2);    checkLedState();
    data.add(current3);    checkLedState();
  data = doc["powers"].to<JsonArray>();    checkLedState();
    data.add(power1);    checkLedState();
    data.add(power2);    checkLedState();
    data.add(power3);    checkLedState();
  data = doc["energies"].to<JsonArray>();    checkLedState();
    data.add(energy1);    checkLedState();
    data.add(energy2);    checkLedState();
    data.add(energy3);    checkLedState();
  data = doc["frequencies"].to<JsonArray>();    checkLedState();
    data.add(frequency1);    checkLedState();
    data.add(frequency2);    checkLedState();
    data.add(frequency3);    checkLedState();
  data = doc["powerFactories"].to<JsonArray>();    checkLedState();
    data.add(pf1);    checkLedState();
    data.add(pf2);    checkLedState();
    data.add(pf3);    checkLedState();
  // Добавить объекты в JSON документ
  JsonObject FullValues =  doc["FullValues"].to<JsonObject>();    checkLedState();
    FullValues["current"] = current;    checkLedState();
    FullValues["power"] = power;    checkLedState();
    FullValues["energy"] = energy;    checkLedState();
  JsonObject ResSMDValues =  doc["ResSMDValues"].to<JsonObject>();    checkLedState();
    ResSMDValues["KYimpNumSumm"] = KYimpNumSumm;    checkLedState();
    ResSMDValues["SMDimpPeriod"] = meterBlinkPeriod;    checkLedState();
    if (printSMDAccuracy) {
      ResSMDValues["SMDpower"] = meterWattage;    checkLedState();
      if (power) ResSMDValues["SMDAccuracy"] = (power - meterWattage) / power * 100;    checkLedState();
      printSMDAccuracy = false;
    }
  server.send(200, "application/json", doc.as<String>());    checkLedState();
  yield();    checkLedState();
}

В комментариях на меня накинутся за такой код и будут вполне правы. Это топорное, но простое и быстрое «решение». Для серьёзной разработки оно, конечно, не годится.

Но. Во-первых, это работает. Проверено при периоде GET-запросов 1 с и при различных частотах моргания разных приборов учёта.
Во-вторых, нет необходимости создавать отдельную задачу опроса ESP каждый раз при получении GET request. Ну или нам пришлось бы создавать «жёсткую» статическую задачу отправки PZEM-данных с ESP при страте ESP. Т.е. отправлять данные без запроса со стороны веб-браузера, а поэтому пришлось бы разбираться с веб-сокетами.

4.10 Расчёт мощности и погрешности прибора учёта электроэнергии по импульсам и измерениям PZEM

Мощность прибора учёта электроэнергии рассчитывается по следующей формуле:

P=(3600*n)/(A*t)

где n - кол-во импульсов;
А - передаточное число ПУ (постоянная счётчика пишется на корпусе рядом с моргающим светодиодом), имп/кВ*ч;
t - время, с.

время между импульсами вычисляется внутри checkLedState():

...
unsigned long microTimer;                      // Стоп-таймер в микросекундах
double meterBlinkPeriod;                       // Период моргания счётчика
boolean ledState, ledStateOld;                 // текущее логическое состояние фоторезистора
...
void checkLedState() {
  ...
  ledStateOld = ledState; // сохраняем в буфер старое значение уровня сенсора
  ...
    if (ledStateOld && !ledState) {  // ИНДикатор только что загорелся
      ...
      meterBlinkPeriod = double(micros() - microTimer) / 1000000;// длина последнего импульса = текущее время - время прошлого перехода
      microTimer = micros(); // запоминаем время этого перехода в таймер
      ...
    }
  ...
}

Если пользователь на фронте в веб-интерфейсе задал число импульсов, необходимое для расчёта погрешности, то все длины последовательных импульсов записываются в очередь такого же размера:

...
#include <queue>
...
std::queue<double> meterBlinkPeriods; // Очередь из последних периодов моргания счётчика    
size_t queueSize = 1;
double queueSum = 0;
...
void checkLedState() {
  ...
    if (queueSize > 1) {
        printSMDAccuracy = false; //запрещаем отправлять погрешность на фронт
        meterBlinkPeriods.push(meterBlinkPeriod); // добавляем период моргания в очередь, если пользователь задал её длину > 1
        queueSum += meterBlinkPeriod;
        if (meterBlinkPeriods.size() == queueSize) { //если очередь переполнена то
            /*queueSum -= meterAccuracy.front(); // корректируем сумму очереди
            meterAccuracy.pop(); // удаляем первый элемент, если очередь переполнена*/
            meterBlinkPeriod = queueSum / meterBlinkPeriods.size(); // рассчитываем среднюю длину импульса
            while (!meterBlinkPeriods.empty()) meterBlinkPeriods.pop(); // очищаем очередь
            queueSum = 0; // обнуляем сумму длин импульсов
            meterWattage = 3600 / meterBlinkPeriod  / constMeterImpsNum; // нагрузка (кВт) = кол-во таких импульсов в часе разделить на имп за 1кВт*ч
            printSMDAccuracy = true; //разрешаем отправлять погрешность на фронт
        }
    }
  ...
} 

Погрешность в % вычисляется по формуле:

Р=(Рpzem-Рпу)/Рpzem*100%

Погрешность лучше вычислять непосредственно перед отправкой на фронт в формате json:

    if (printSMDAccuracy) {
      ResSMDValues["SMDpower"] = meterWattage;checkLedState();
      if (power) ResSMDValues["SMDAccuracy"] = (power - meterWattage) / power * 100;checkLedState();
      printSMDAccuracy = false;
    }

5. Корпус

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

устройство в собранном виде в корпусе
устройство в собранном виде в корпусе

Корпус был взят готовый (ищите – «Корпус для РЭА пластиковый настольный RUICHI»). Разъёмы были скопированы у РЕТОМЕТР-М3. Фоторезистор и трансформаторы тока, которые входили в состав PZEM, переделаны: были припаяны jack 3.5 штекеры для быстрого подключения к корпусу (в который были встроены AUX порты). Для цепей напряжения используется кабель общего назначения КОН 61.04. Шасси и корпус для датчика были напечатаны на 3D-принтере.

Процесс печати корпуса для фоторезистора
Процесс печати корпуса для фоторезистора

Репозиторий проекта

Особые благодарности источникам:

1. Подключаем Ардуино К Счётчику

2. Подключение нескольких PZEM-004t на ESP

3. Веб-сервер AJAX на ESP8266: динамическое обновление веб-страниц без их перезагрузки

Комментарии (43)


  1. MamOn
    08.02.2025 12:24

    Спасибо, довольно актуальна для меня статья, как раз на почте дожидаются 3 штуки pzem-004t.


  1. Javian
    08.02.2025 12:24

    А зачем в полевых условиях проверять? Счётчики с подстанций проверяют в метрологии на специальных стендах с незапамятных времён. Разве что в нынешние времена метрологию эффективно развалили.


    1. airattu Автор
      08.02.2025 12:24

      В электросетевых компаниях контролёры часто ходят по приборам учёта потребителей если есть подозрения, что они некорректно работают. Если обнаружат, что ИПУ неверно считает, тогда уже снимают и везут в лабораторию. Этих ИПУ сотни тысяч, поэтому везти каждый в лабораторию при подозрении на неполадки было бы накладно.


      1. Javian
        08.02.2025 12:24

        А потребление не анализируют? Аномалии должны быть видны.


        1. airattu Автор
          08.02.2025 12:24

          Да, анализируют на верхнем уровне вполне эффективно. Сейчас этим куча компаний занимается, даже МТС. Нейросеть активно используют для этого. Но обход ногами все равно практикуется. Особенно там где нет связи или сбора данных.


  1. Gudd-Head
    08.02.2025 12:24

    Опять киловатты делят на часы...


    1. airattu Автор
      08.02.2025 12:24

      Опечатку исправил, спасибо. Скопировал не глядя описание видимо


  1. sergyk2
    08.02.2025 12:24

    в есп разве аппаратного щетщика импульсов нет?

    да и метрологический вопрос не раскрыт, почему вы щитаете что этот показометр что-то измеряет?


    1. airattu Автор
      08.02.2025 12:24

      К аналоговому входу esp подключён фоторезистор, который считывает периодическое загорание светодиода на приборе учёта. Для расчёта мощности необходимо вычислить время между загораниями. Количество импульсов (загораний светодиода) считать не нужно, ведь размер очереди времен между импульсами задаётся пользователем.

      "показометр" производит измерения тока и напряжения с помощью трех PZEM. За метрологию отвечают они. Esp считывает данные с них.


  1. RomZa77
    08.02.2025 12:24

    По хорошему на PZEM надо подавать не 5v, а 3.3v. Тогда на RX/TX будут 3.3v, что для ESP8266 будет более благоприятно. Много самоделок и у меня где на GPIO ESP8266 подаю 5v или чуть ниже, но существенно больше 3.3v, и вроде работают. Но, вот столкнулся с ESP32, там было много сил и времени потрачено на выяснение почему она виснет, пока не вычислил, что поданные снаружи 5v на GPIO как Digital Input во всем виноват, считал импульсы от одного прибора, добавил резистивный делитель перед входом на ESP и получив 3.3v все пришло в норму. На PZEM там этими 5v питается половина от двух оптопар, подтяжка линий TX/RX через резистор от этих же входных 5v и пара светодиодов через резистор, все, больше там ничего в эту сторону от оптопар, куда идет 4-х пиновый разъем нет. И все это замечательно питается и от 3.3v подтягивая TX/RX к каноническим для ESP 3.3v.

    Да и на модуль KY-018 - фоторизистор тоже надо 3.3v подавать. Pin ADC у ESP ожидает 0-1v, на NODE MCU перед входом стоит делитель 220к+100к, который 3,3v поделит и на вход Pin ADC у ESP будет 1v.


    1. xSVPx
      08.02.2025 12:24

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


  1. xSVPx
    08.02.2025 12:24

    А прямо на esp померять не удастся ? Ток можно легко снять, там ведь с датчика идёт прям напряжение пропорциональное току. Напряжение, полагаю можно снять через делитель(ну или трансформатор, если уж совсем кошерно). Проинтегрировать. В крайнем случае внешний ацп стоит копейки по сравнению даже с одним таким модулем.


    1. airattu Автор
      08.02.2025 12:24

      На esp один аналоговый вход. 100 А на него не подашь. Тем более 3 фазы. Если самому делать делитель, вряд-ли он будет измерять лучше PZEM. Да и какой смысл в таком велосипеде, если есть готовые протестированные решения, которые стоят копейки. Разве что для изучения.


      1. xSVPx
        08.02.2025 12:24

        10$/канал не копейки совершенно. Впрочем согласен, что надо считать. Для 30$ может и не надо, если их хотя бы 2-3шт надо, то может и надо...

        У меня лежит несколько токовых петель, так прям на них написано 1-ХА выход 1-ХВ, т.е. они очевидно на выходе имеют напряжение уже прям.

        Что один вход - это да, проблема, внешний АЦП придется городить, и он, вероятно всю ощутимую экономию сожрет :(. Но тут дело даже не в экономии, у меня совершенно к этому готовому черному ящику доверия нету. Впрочем это дело тоже сугубо субъективное.

        Но в целом логика есть, я как-то не привык совершенно что ацп один...


        1. Yuri0128
          08.02.2025 12:24

          Ну, чаще всего, АЦП в контроллере один, реже - два. Просто стоит коммутатор, который подключает какой-то из вешних каналов. Что мешает подключить подобный внешний коммутатор/мультиплексер на вход АЦП? Ничего, и цена ему - копейки.

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

          Кроме того, 0,5% резисторы с хорошим ТКС от известных производителей стоят весьма ощутимо, а их на 400В надо 1206 и не по 1 шт на делитель, нормальные токовые трансы тоже таки недешевые. Посему $7 на канал - не настолько уж и дорого, причем нафиг уходит проблема с калибровкой.

          Вот подключение их к блоку сильно колхозное и для нормальной работы требует переделки - это вот минус.Защит на TX/RX тоже не видать - в полевых условиях разное бывает, они нужны обязательно.


        1. airattu Автор
          08.02.2025 12:24

          Я думаю ни один метролог не станет доверять подобной железке, её ведь нет в реестре СИ, и акт поверки не выпишешь на неё. Но для определённых измерений использовать можно под свою ответственность. Тем более есть возможность её откалибровать.


          1. Yuri0128
            08.02.2025 12:24

            Так и не доверяют. Это показометр, который позволяет отсеять 50-80% ненужной работы. Межповерочный период никто же не отменял.


  1. randomsimplenumber
    08.02.2025 12:24

    Я правильно понимаю, что приезжает электрик отключает счётчик, одевает кольца на провода, измеряет, отключает счётчик, снимает кольца , подключает счётчик? У него нет токоизмерительных клещей, чтобы сделать изменение одним движением?


    1. airattu Автор
      08.02.2025 12:24

      Не нужно отключать счётчик. У pzem трансформаторы тока разъемные. Можно ими пользоваться как клещами. Напряжение с помощью крокодилов снимается. Т. е. сценарий работы максимально прост: подошёл, подцепил и получил погрешность прибора учёта.


    1. xSVPx
      08.02.2025 12:24

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


      1. randomsimplenumber
        08.02.2025 12:24

        я электрик ненастоящий.. но настоящие электрики с 3 фазными сетями больше 100 лет дело имеют. как они эту задачу решали раньше, без Ардуино?


        1. Javian
          08.02.2025 12:24

          До недавнего времени частные абоненты не требовали 15кВт на трёх фазах, а обходились однофазным счётчиком и потребляемой мощностью в 2 кВт.

          А у промышленности другие сети и они ещё платят за реактивную мощность.


          1. Yuri0128
            08.02.2025 12:24

            Уже давно есть абоненты не только с 15 кВт а и с 30 кВт. Именно частные лица. Да и однофазные сейчас меньше 5 кВт не заказывают.


            1. Javian
              08.02.2025 12:24

              Конечно частные есть с бассейнами, банями, гостевыми домами и не 6 сотках .


              1. Yuri0128
                08.02.2025 12:24

                Еще быструю зарядку для электромобиля забыли. Оно 22+ кВт.


        1. xSVPx
          08.02.2025 12:24

          Как-то, но если ТС будучи настоящим электриком принял решение решать задачу по-другому, то это как-бы намекает...

          Возможно в прошлом эту задачу легко и просто было не решить вообще. Никак. Возможно приходилось ждать накинув клещи и считать импульсы вчетвером (трое с клещами, один на светодиоде). Возможно, что-то еще.


          1. Yuri0128
            08.02.2025 12:24

            Выше писал - ставился параллельно счетчик. Никто не сидел и не считал импулься (а тогда, то есть "давно", их и не было, импульсов разумеется. Был чистой воды аналоговый сигнал).


            1. xSVPx
              08.02.2025 12:24

              Егож вроде последовательно надо ставить, счетчик ? А это значит - обесточить абонента итд итп

              ЗЫ. А, вижу, трансформаторы отдельно накидывали. Ну оно в целом и есть.

              Подозреваю описанное решение сильно быстрее разбег показывает, т.е. ждать надо меньше времени.


              1. Yuri0128
                08.02.2025 12:24

                Нет. Я же написал - на линейные провода набрасываются разъемные трансы тока, сам счетчик подключается по напряжению параллельно имеющемуся.

                Решение со статьи позволяет оценить обшибку, ну за 10 минут. Со счетчиком - час и больше.


                1. randomsimplenumber
                  08.02.2025 12:24

                  А что там делать час? Сосчитал количество импульсов за минуту. Измерил ток.Измерил напряжение . Подставил в формулу. Чтобы оценить, считает ли счётчик, этого достаточно. кмк.

                  Возможно, у них в хозяйстве такая приблуда есть готовая, с прописанным классом точности и пройденной поверкой.


                  1. Yuri0128
                    08.02.2025 12:24

                    А что там делать час? Сосчитал количество импульсов за минуту. 

                    Не считали число импульсов - не было в тех счетчиках светодиода. Нечего было считать.


                    1. randomsimplenumber
                      08.02.2025 12:24

                      Сосчитали число оборотов диска, если древний счётчик ещё на ходу. Прямо на нем написано, чему оборот соответствует - это же неспроста?


                      1. Yuri0128
                        08.02.2025 12:24

                        Ага, вот только нафига - там механический счетчик с цифирьками... Вы сами попробуйте посчитать вручную в течение 10 минут обороты 2 счетчиков одновременно и не ошибиться.


        1. airattu Автор
          08.02.2025 12:24

          Опытным электрикам все эти новые приблуды действительно вряд-ли нужны, но сейчас острый дефицит профессионалов, поэтому везде хотят все роботизировать, автоматизировать и оптимизировать)


          1. Yuri0128
            08.02.2025 12:24

            С чего такой вывод? Если электрик экономит свое время - то он с радостью будет юзать такую приблуду. А вот ежели электрик "опытный" в смысле нихрена нового не принимает и тупенький - ну да, оно ему нахрен не надо. Ибо думать надо. А мозги уже не хотят.


            1. Javian
              08.02.2025 12:24

              А откуда вообще взялась такая задача у электрика? Какая то самодеятельность если работник необеспечен рабочим инструментом для выполнения работы. Не проведено обучение .


              1. Yuri0128
                08.02.2025 12:24

                Ему диспетчер задачу поставил - он и выполняет.


                1. Javian
                  08.02.2025 12:24

                  Значит должен быть обеспечен поверенными протестированными инструментами.
                  В случае несчастного случая прикопаются к этой самоделке. Иногда просматриваю обзор несчастных случаев на производстве или в электроустановках. Всё как в Крыжовнике А.П. Чехова:

                  ... но мы не видим и не слышим тех, которые страдают, и то, что страшно в жизни, происходит где-то за кулисами. Все тихо, спокойно, и протестует одна только немая статистика: столько-то с ума сошло, столько-то ведер выпито, столько-то детей погибло от недоедания… И такой порядок, очевидно, нужен; очевидно, счастливый чувствует себя хорошо только потому, что несчастные несут свое бремя молча, и без этого молчания счастье было бы невозможно. Это общий гипноз. Надо, чтобы за дверью каждого довольного, счастливого человека стоял кто-нибудь с молоточком и постоянно напоминал бы стуком, что есть несчастные, что, как бы он ни был счастлив, жизнь рано или поздно покажет ему свои когти, стрясется беда – болезнь, бедность, потери, и его никто не увидит и не услышит, как теперь он не видит и не слышит других. Но человека с молоточком нет, счастливый живет себе, и мелкие житейские заботы волнуют его слегка, как ветер осину, – и все обстоит благополучно.


                  1. Yuri0128
                    08.02.2025 12:24

                    Значит должен быть обеспечен поверенными протестированными инструментами.

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


        1. Yuri0128
          08.02.2025 12:24

          Так собственно так же - ставили параллельно "поверочный" счетчик и трансформаторы набрасывали на линейные провода. Если разница большая - клиентский счетчик на поверку. Небольшая - не трогали. Тут роль счетчика и играет ESP8266 с датчиками а все остальное - для упрощения проектирования как аппаратной части так и программной.


  1. stigory
    08.02.2025 12:24

    Меньше пятидесяти строк в файле конфигурации ESPHome решило похожую задачу без необходимости собирания кода из лоскутов чужих разработок.


    1. xSVPx
      08.02.2025 12:24

      А вы разработчик esphome?


      1. Yuri0128
        08.02.2025 12:24

        Вряд-ли stigory из Open Home Foundation. Чего-то я так не думаю.