Задача разработки — быстрая проверка прибора учёта электроэнергии в полевых условиях. Устройство должно обладать низкой стоимостью, высокой мобильностью и более простым интерфейсом в сравнении с аналогом — Энергомонитор-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: динамическое обновление веб-страниц без их перезагрузки

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


  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

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