- PVSM.RU - https://www.pvsm.ru -

Разработка трёхфазного энергомонитора на базе ESP8266 с функцией автоматической проверки прибора учёта

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

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

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

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

1. IDE

Выбор IDE

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

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

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

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

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

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

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

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

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

Необходимо также подключить по инструкции [19] дополнительные ссылки для Менеджера плат для NodeMCU: http://arduino.esp8266.com/stable/package_esp8266com_index.json [20] (в 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) [21].

Я для наглядности реализовал такую логику: пробуем подключиться к 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 [22], который позволит пользователю настраивать сеть самому, к тому же обеспечит более стабильное подключение.

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-ю ошибку [23] в случае проблем с сервером. т.е. с 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 [24], script.js [25] и style.css [26]

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

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

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

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 [28] к основному проекту:

#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 [28] при каждой заливке прошивки или при любом изменении index.html [28], script.js [25] и style.css [26] (я в своём проекте пока этим заморачивался). MakeStrFromWeb() можно написать на javascript [29], что будет более логичным в контексте фронт разработки.
Можно также загрузить index.html [28], script.js [25] и style.css [26] в ESP с помощью файловой системы SPIFFS [30]. Подробнее - ESP8266 Web Server using SPIFFS (SPI Flash File System) – NodeMCU [31]
Но расширения, к сожалению не поддерживаются [32]Arduino IDE 2.0.x, только версиями 1.x. Но если хотите, можете скачать [33]старую IDE и заморочиться с SPIFFS (я пробовал – работает). К тому же ARDUINO 1.8.18 работает весьма неплохо.

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

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

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() [36] до и после тяжёлых блоков кода. Также в чужих скетчах можно встретить delay(0), по сути, это и есть yield().

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

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

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

Всё по порядку:
- п.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

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

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✔️nr");
      }
    };
    xhttp.onload = function () {
        ViewAllESPdata(xhttp.response);
    };
};

функция ViewAllESPdata(ESPdata) получает JSON, парсит его и выводит в веб-интерфейс с заданным кол-вом точек после запятой. Также она запускает функцию записи всех данных в массив для последующего сохранения в csv [41](подробности см. в репозитории проекта [25])
Обратите внимание на строчку 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✔️nr");
                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 [25], для того, чтобы вытащить Ктт.
Примерно также мы будем сбрасывать PZEMы с помощью функции Reset() в script.js [25]:

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 Считывание импульсов с прибора учёта с помощью фоторезистора

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

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

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

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

Многозадачность в 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. Т.е. отправлять данные без запроса со стороны веб-браузера, а поэтому пришлось бы разбираться с веб-сокетами [49].

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. Корпус

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

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

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

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

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

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

Репозиторий проекта [53]

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

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

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

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

Автор: airattu

Источник [54]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/arduino/410149

Ссылки в тексте:

[1] Visual Studio Code: https://code.visualstudio.com/

[2] расширений : https://randomnerdtutorials.com/vs-code-platformio-ide-esp32-esp8266-arduino/

[3] PlatformIO: https://platformio.org/platformio-ide

[4] Espressif IDE: https://docs.espressif.com/projects/esp-idf/en/stable/esp32/get-started/index.html

[5] самостоятельной IDE: https://dl.espressif.com/dl/esp-idf/?idf=5.1

[6] EclipseCDT: https://projects.eclipse.org/projects/tools.cdt

[7] Как и на чём программировать ESP32 и ESP8266: https://kotyara12.ru/iot/esp_start/

[8] Переползаем с Arduino IDE на VSCode + PlatformIO: https://kotyara12.ru/iot/crawl-to-pio/

[9] ESP32 в окружении VSCode: https://habr.com/ru/articles/530638/

[10] Arduino IDE: https://www.arduino.cc/en/software

[11] ESP8266 NodeMCU V3: https://%20https:/arduinomaster.ru/platy-arduino/esp8266-nodemcu-v3-lua/

[12] 3 х PZEM-004T V3.0: https://%20https:/innovatorsguru.com/wp-content/uploads/2019/06/PZEM-004T-V3.0-Datasheet-User-Manual.pdf

[13] KY-018 : https://eclass.uth.gr/modules/document/file.php/E-CE_U_269/Sensors/Sensors_%20Datasheets/KY-018-Joy-IT.pd

[14] по инструкции: https://wiki.iarduino.ru/page/Installing_libraries

[15] https://github.com/mandulaj/PZEM-004T-v30: https://github.com/mandulaj/PZEM-004T-v30

[16] https://github.com/esp8266/Arduino: https://github.com/esp8266/Arduino

[17] https://github.com/bblanchon/ArduinoJson: https://github.com/bblanchon/ArduinoJson

[18] https://arduinojson.org/?utm_source=meta&utm_medium=library.properties: https://arduinojson.org/?utm_source=meta&utm_medium=library.properties

[19] инструкции: https://robotclass.ru/articles/node-mcu-arduino-ide-setup/

[20] http://arduino.esp8266.com/stable/package_esp8266com_index.json: http://arduino.esp8266.com/stable/package_esp8266com_index.json

[21] ESP32 Useful Wi-Fi Library Functions (Arduino IDE): https://randomnerdtutorials.com/esp32-useful-wi-fi-functions-arduino/

[22] подключить нормальный WiFiManager: https://github.com/tzapu/WiFiManager

[23] 404-ю ошибку: https://ru.wikipedia.org/wiki/%D0%9E%D1%88%D0%B8%D0%B1%D0%BA%D0%B0_404

[24] index.html: https://github.com/Ayrat123T/Arduino_PZEM-nodemcu-web-wifi_three_phase_electricity_meter/blob/main/front/index.html

[25] script.js: https://github.com/Ayrat123T/Arduino_PZEM-nodemcu-web-wifi_three_phase_electricity_meter/blob/main/front/script.js

[26] style.css: https://github.com/Ayrat123T/Arduino_PZEM-nodemcu-web-wifi_three_phase_electricity_meter/blob/main/front/style.css

[27] скрипт: https://github.com/Ayrat123T/Arduino_PZEM-nodemcu-web-wifi_three_phase_electricity_meter/blob/main/front/htmlToH.cpp

[28] index.h: https://github.com/Ayrat123T/Arduino_PZEM-nodemcu-web-wifi_three_phase_electricity_meter/blob/main/srs/PZEM_nodemcu_three_phase/index.h

[29] можно написать на javascript: https://github.com/tzapu/WiFiManager/blob/master/extras/parse.js

[30] SPIFFS: https://amperkot.ru/blog/esp-spiffs/?srsltid=AfmBOop6dRzyBR1e3GQaN9ZqxOoA70iNpD8Httbj2FyRlWnbHdzCTs7C

[31] ESP8266 Web Server using SPIFFS (SPI Flash File System) – NodeMCU: https://randomnerdtutorials.com/esp8266-web-server-spiffs-nodemcu/

[32] не поддерживаются : https://forum.arduino.cc/t/using-the-filesystem-spiffs-with-arduino-ide-2-0-x-is-problematic/1162671

[33] скачать : https://www.arduino.cc/en/software/OldSoftwareReleases

[34] библиотеку PZEM004Tv30: https://github.com/mandulaj/PZEM-004T-v30/tree/master

[35] пример: https://github.com/mandulaj/PZEM-004T-v30/blob/master/examples/PZEMHardSerial/PZEMHardSerial.cpp

[36] yield(): https://alexgyver.ru/lessons/esp8266/

[37] этом : https://rain.linuxoid.in/2021/03/07/%D0%BF%D0%BE%D0%B4%D0%BA%D0%BB%D1%8E%D1%87%D0%B5%D0%BD%D0%B8%D0%B5-%D0%BD%D0%B5%D1%81%D0%BA%D0%BE%D0%BB%D1%8C%D0%BA%D0%B8%D1%85-pzem-004t-%D0%BD%D0%B0-esp/

[38] AJAX : https://developer.mozilla.org/ru/docs/Learn_web_development/Core/Scripting/Network_requests

[39] Веб-сервер AJAX на ESP8266: динамическое обновление веб-страниц без их перезагрузки: https://microkontroller.ru/esp8266-projects/veb-server-ajax-na-esp8266-dinamicheskoe-obnovlenie-veb-stranicz-bez-ih-perezagruzki/

[40] воспользуемся JSON: https://developer.mozilla.org/ru/docs/Learn_web_development/Core/Scripting/JSON

[41] csv : https://github.com/Ayrat123T/Arduino_PZEM-nodemcu-web-wifi_three_phase_electricity_meter/blob/main/2025_1_21%2016_16_27%20exportSmartGridComMeterData.csv

[42] Подключаем ардуино к счётчику: https://www.instructables.com/%D0%9F%D0%BE%D0%B4%D0%BA%D0%BB%D1%8E%D1%87%D0%B0%D0%B5%D0%BC-%D0%B0%D1%80%D0%B4%D1%83%D0%B8%D0%BD%D0%BE-%D0%BA-%D1%81%D1%87%D1%91%D1%82%D1%87%D0%B8%D0%BA%D1%83/

[43] meterBlinkPeriodCalc.h: https://github.com/Ayrat123T/Arduino_PZEM-nodemcu-web-wifi_three_phase_electricity_meter/blob/main/srs/PZEM_nodemcu_three_phase/meterBlinkPeriodCalc.h

[44] Энергомонитор-3.3 Т1: https://www.mars-energo.ru/home/poverochnye-ustanovki-i-etalony-dlya-elektroschetchikov/etalonnyie-schetchiki/energomonitor-3.3t1-s.html

[45] FIFO: https://ru.wikipedia.org/wiki/FIFO

[46] ОСРВ: https://ru.wikipedia.org/wiki/%D0%9E%D0%BF%D0%B5%D1%80%D0%B0%D1%86%D0%B8%D0%BE%D0%BD%D0%BD%D0%B0%D1%8F_%D1%81%D0%B8%D1%81%D1%82%D0%B5%D0%BC%D0%B0_%D1%80%D0%B5%D0%B0%D0%BB%D1%8C%D0%BD%D0%BE%D0%B3%D0%BE_%D0%B2%D1%80%D0%B5%D0%BC%D0%B5%D0%BD%D0%B8

[47] ESP8266_RTOS_SDK: https://github.com/espressif/ESP8266_RTOS_SDK

[48] Многозадачность в Arduino: https://alexgyver.ru/lessons/how-to-sketch/

[49] веб-сокетами: https://ru.stackoverflow.com/questions/684112/%D0%92%D0%BE%D0%B7%D0%BC%D0%BE%D0%B6%D0%BD%D0%BE-%D0%BB%D0%B8-%D0%BE%D1%82%D0%BF%D1%80%D0%B0%D0%B2%D0%B8%D1%82%D1%8C-response-%D0%B1%D0%B5%D0%B7-%D0%B7%D0%B0%D0%BF%D1%80%D0%BE%D1%81%D0%25B

[50] готовый : https://ruichi.ru/ustanovochnye-izdeliya/korpusa-dlya-rea/korpus-dlia-rea-ruichi-15-12-195kh175kh70-nastolnyi/

[51] РЕТОМЕТР-М3: https://dynamics.com.ru/production/retom-21-complex/retometr-m3

[52] КОН 61.04: https://dynamics.com.ru/userfiles/file/product/dka-61/dka.pdf

[53] Репозиторий проекта: https://github.com/Ayrat123T/Arduino_PZEM-nodemcu-web-wifi_three_phase_electricity_meter/tree/main

[54] Источник: https://habr.com/ru/articles/880682/?utm_source=habrahabr&utm_medium=rss&utm_campaign=880682