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

в 11:15, , рубрики: ajax, arduino, esp8266, HTTP-запрос, KY-018, pzem004t, веб-интерфейс, разработка устройства, учет электроэнергии, энергомониторинг

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

Автор: airattu

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js