From 852ae0638cc2b347f6557577898a1769b644893c Mon Sep 17 00:00:00 2001 From: Bernhard Kaszt Date: Sat, 1 Apr 2023 17:41:09 +0200 Subject: [PATCH 1/5] Implement HTTP(s) + JSON type Power Meter support --- README.md | 2 +- include/Configuration.h | 20 ++- include/HttpPowerMeter.h | 19 +++ include/PowerMeter.h | 8 + include/WebApi_powermeter.h | 3 +- platformio.ini | 1 + src/Configuration.cpp | 27 ++- src/HttpPowerMeter.cpp | 127 ++++++++++++++ src/PowerLimiter.cpp | 3 +- src/PowerMeter.cpp | 107 +++++++----- src/WebApi_powermeter.cpp | 142 ++++++++++++++- webapp/src/locales/de.json | 17 +- webapp/src/locales/en.json | 17 +- webapp/src/types/PowerMeterConfig.ts | 12 ++ webapp/src/views/PowerMeterAdminView.vue | 209 ++++++++++++++++++++--- 15 files changed, 636 insertions(+), 78 deletions(-) create mode 100644 include/HttpPowerMeter.h create mode 100644 src/HttpPowerMeter.cpp diff --git a/README.md b/README.md index cc9a94071..74188be7c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ This is a fork from the Hoymiles project OpenDTU. This project is still under development and adds following features: * Support Victron's Ve.Direct protocol on the same chip (cable based serial interface!). Additional information about Ve.direct can be downloaded from https://www.victronenergy.com/support-and-downloads/technical-information. -* Dynamically sets the Hoymiles power limited according to the currently used energy in the household (needs an MQTT based power meter like Shelly 3EM) +* Dynamically sets the Hoymiles power limited according to the currently used energy in the household. Needs an HTTP JSON based power meter (e.g. Tasmota), an MQTT based power meter like Shelly 3EM or an SDM power meter. * Battery support: Read the voltage from Victron MPPT charge controller or from the Hoymiles DC inputs and starts/stops the power producing based on configurable voltage thresholds * Voltage correction that takes the voltage drop because of the current output load into account (not 100% reliable calculation) * Can read the current solar panel power from the Victron MPPT and adjust the limiter accordingly to not save energy in the battery (for increased system efficiency). Increases the battery lifespan and reduces energy loses. diff --git a/include/Configuration.h b/include/Configuration.h index 96e955536..2894b3e04 100644 --- a/include/Configuration.h +++ b/include/Configuration.h @@ -29,6 +29,13 @@ #define DEV_MAX_MAPPING_NAME_STRLEN 63 +#define POWERMETER_MAX_PHASES 3 +#define POWERMETER_MAX_HTTP_URL_STRLEN 1024 +#define POWERMETER_MAX_HTTP_HEADER_KEY_STRLEN 64 +#define POWERMETER_MAX_HTTP_HEADER_VALUE_STRLEN 256 +#define POWERMETER_MAX_HTTP_JSON_PATH_STRLEN 256 +#define POWERMETER_HTTP_TIMEOUT 1000 + #define JSON_BUFFER_SIZE 12288 struct CHANNEL_CONFIG_T { @@ -47,6 +54,15 @@ struct INVERTER_CONFIG_T { CHANNEL_CONFIG_T channel[INV_MAX_CHAN_COUNT]; }; +struct POWERMETER_HTTP_PHASE_CONFIG_T { + bool Enabled; + char Url[POWERMETER_MAX_HTTP_URL_STRLEN + 1]; + char HeaderKey[POWERMETER_MAX_HTTP_HEADER_KEY_STRLEN + 1]; + char HeaderValue[POWERMETER_MAX_HTTP_HEADER_VALUE_STRLEN + 1]; + uint16_t Timeout; + char JsonPath[POWERMETER_MAX_HTTP_JSON_PATH_STRLEN + 1]; +}; + struct CONFIG_T { uint32_t Cfg_Version; uint Cfg_SaveCount; @@ -107,7 +123,9 @@ struct CONFIG_T { char PowerMeter_MqttTopicPowerMeter3[MQTT_MAX_TOPIC_STRLEN + 1]; uint32_t PowerMeter_SdmBaudrate; uint32_t PowerMeter_SdmAddress; - + uint32_t PowerMeter_HttpInterval; + bool PowerMeter_HttpIndividualRequests; + POWERMETER_HTTP_PHASE_CONFIG_T Powermeter_Http_Phase[POWERMETER_MAX_PHASES]; bool PowerLimiter_Enabled; bool PowerLimiter_SolarPassTroughEnabled; diff --git a/include/HttpPowerMeter.h b/include/HttpPowerMeter.h new file mode 100644 index 000000000..c704799d5 --- /dev/null +++ b/include/HttpPowerMeter.h @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include + +class HttpPowerMeterClass { +public: + void init(); + bool updateValues(); + float getPower(int8_t phase); + bool httpRequest(const char* url, const char* httpHeader, const char* httpValue, uint32_t timeout, + char* response, uint32_t responseSize, char* error, uint32_t errorSize); + float getFloatValueByJsonPath(const char* jsonString, const char* jsonPath, float &value); + +private: + float power[POWERMETER_MAX_PHASES]; +}; + +extern HttpPowerMeterClass HttpPowerMeter; diff --git a/include/PowerMeter.h b/include/PowerMeter.h index da677c0b5..8b1ff3355 100644 --- a/include/PowerMeter.h +++ b/include/PowerMeter.h @@ -18,6 +18,12 @@ class PowerMeterClass { public: + enum SOURCE { + SOURCE_MQTT = 0, + SOURCE_SDM1PH = 1, + SOURCE_SDM3PH = 2, + SOURCE_HTTP = 3, + }; void init(); void mqtt(); void loop(); @@ -27,6 +33,8 @@ class PowerMeterClass { private: uint32_t _interval; + uint32_t _lastPowerMeterCheck; + // Used in Power limiter for safety check uint32_t _lastPowerMeterUpdate; float _powerMeter1Power = 0.0; diff --git a/include/WebApi_powermeter.h b/include/WebApi_powermeter.h index 2175029cb..4651dfbbd 100644 --- a/include/WebApi_powermeter.h +++ b/include/WebApi_powermeter.h @@ -13,6 +13,7 @@ class WebApiPowerMeterClass { void onStatus(AsyncWebServerRequest* request); void onAdminGet(AsyncWebServerRequest* request); void onAdminPost(AsyncWebServerRequest* request); + void onTestHttpRequest(AsyncWebServerRequest* request); AsyncWebServer* _server; -}; \ No newline at end of file +}; diff --git a/platformio.ini b/platformio.ini index fef70c0ac..45ecd69d1 100644 --- a/platformio.ini +++ b/platformio.ini @@ -33,6 +33,7 @@ lib_deps = olikraus/U8g2 @ ^2.34.16 buelowp/sunset @ ^1.1.7 https://github.com/coryjfowler/MCP_CAN_lib + mobizt/FirebaseJson @ ^3.0.6 extra_scripts = pre:auto_firmware_version.py diff --git a/src/Configuration.cpp b/src/Configuration.cpp index f8be908e0..ebba243df 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -124,6 +124,19 @@ bool ConfigurationClass::write() powermeter["mqtt_topic_powermeter_3"] = config.PowerMeter_MqttTopicPowerMeter3; powermeter["sdmbaudrate"] = config.PowerMeter_SdmBaudrate; powermeter["sdmaddress"] = config.PowerMeter_SdmAddress; + powermeter["http_individual_requests"] = config.PowerMeter_HttpIndividualRequests; + + JsonArray powermeter_http_phases = powermeter.createNestedArray("http_phases"); + for (uint8_t i = 0; i < POWERMETER_MAX_PHASES; i++) { + JsonObject powermeter_phase = powermeter_http_phases.createNestedObject(); + + powermeter_phase["enabled"] = config.Powermeter_Http_Phase[i].Enabled; + powermeter_phase["url"] = config.Powermeter_Http_Phase[i].Url; + powermeter_phase["header_key"] = config.Powermeter_Http_Phase[i].HeaderKey; + powermeter_phase["header_value"] = config.Powermeter_Http_Phase[i].HeaderValue; + powermeter_phase["timeout"] = config.Powermeter_Http_Phase[i].Timeout; + powermeter_phase["json_path"] = config.Powermeter_Http_Phase[i].JsonPath; + } JsonObject powerlimiter = doc.createNestedObject("powerlimiter"); powerlimiter["enabled"] = config.PowerLimiter_Enabled; @@ -300,7 +313,19 @@ bool ConfigurationClass::read() strlcpy(config.PowerMeter_MqttTopicPowerMeter3, powermeter["mqtt_topic_powermeter_3"] | "", sizeof(config.PowerMeter_MqttTopicPowerMeter3)); config.PowerMeter_SdmBaudrate = powermeter["sdmbaudrate"] | POWERMETER_SDMBAUDRATE; config.PowerMeter_SdmAddress = powermeter["sdmaddress"] | POWERMETER_SDMADDRESS; - + config.PowerMeter_HttpIndividualRequests = powermeter["http_individual_requests"] | false; + + JsonArray powermeter_http_phases = powermeter["http_phases"]; + for (uint8_t i = 0; i < POWERMETER_MAX_PHASES; i++) { + JsonObject powermeter_phase = powermeter_http_phases[i].as(); + + config.Powermeter_Http_Phase[i].Enabled = powermeter_phase["enabled"] | (i == 0); + strlcpy(config.Powermeter_Http_Phase[i].Url, powermeter_phase["url"] | "", sizeof(config.Powermeter_Http_Phase[i].Url)); + strlcpy(config.Powermeter_Http_Phase[i].HeaderKey, powermeter_phase["header_key"] | "", sizeof(config.Powermeter_Http_Phase[i].HeaderKey)); + strlcpy(config.Powermeter_Http_Phase[i].HeaderValue, powermeter_phase["header_value"] | "", sizeof(config.Powermeter_Http_Phase[i].HeaderValue)); + config.Powermeter_Http_Phase[i].Timeout = powermeter_phase["timeout"] | POWERMETER_HTTP_TIMEOUT; + strlcpy(config.Powermeter_Http_Phase[i].JsonPath, powermeter_phase["json_path"] | "", sizeof(config.Powermeter_Http_Phase[i].JsonPath)); + } JsonObject powerlimiter = doc["powerlimiter"]; config.PowerLimiter_Enabled = powerlimiter["enabled"] | POWERLIMITER_ENABLED; diff --git a/src/HttpPowerMeter.cpp b/src/HttpPowerMeter.cpp new file mode 100644 index 000000000..0841f3ad9 --- /dev/null +++ b/src/HttpPowerMeter.cpp @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#include "Configuration.h" +#include "HttpPowerMeter.h" +#include "MessageOutput.h" +#include +#include +#include + +void HttpPowerMeterClass::init() +{ +} + +float HttpPowerMeterClass::getPower(int8_t phase) +{ + return power[phase - 1]; +} + +bool HttpPowerMeterClass::updateValues() +{ + const CONFIG_T& config = Configuration.get(); + + char response[2000], + errorMessage[256]; + bool success = true; + + for (uint8_t i = 0; i < POWERMETER_MAX_PHASES; i++) { + POWERMETER_HTTP_PHASE_CONFIG_T phaseConfig = config.Powermeter_Http_Phase[i]; + + if (!phaseConfig.Enabled || !success) { + power[i] = 0.0; + continue; + } + + if (i == 0 || config.PowerMeter_HttpIndividualRequests) { + if (!httpRequest(phaseConfig.Url, phaseConfig.HeaderKey, phaseConfig.HeaderValue, phaseConfig.Timeout, + response, sizeof(response), errorMessage, sizeof(errorMessage))) { + MessageOutput.printf("[HttpPowerMeter] Getting the power of phase %d failed. Error: %s\n", + i + 1, errorMessage); + success = false; + } + } + + if (!getFloatValueByJsonPath(response, phaseConfig.JsonPath, power[i])) { + MessageOutput.printf("[HttpPowerMeter] Couldn't find a value with Json query \"%s\"\n", phaseConfig.JsonPath); + success = false; + } + } + + return success; +} + +bool HttpPowerMeterClass::httpRequest(const char* url, const char* httpHeader, const char* httpValue, uint32_t timeout, + char* response, uint32_t responseSize, char* error, uint32_t errorSize) +{ + WiFiClient* wifiClient = NULL; + HTTPClient httpClient; + + response[0] = '\0'; + error[0] = '\0'; + + if (String(url).substring(0, 6) == "https:") { + wifiClient = new WiFiClientSecure; + reinterpret_cast(wifiClient)->setInsecure(); + } else { + wifiClient = new WiFiClient; + } + + if (!httpClient.begin(*wifiClient, url)) { + snprintf_P(error, errorSize, "httpClient.begin failed"); + delete wifiClient; + return false; + } + + httpClient.setFollowRedirects(HTTPC_STRICT_FOLLOW_REDIRECTS); + httpClient.setUserAgent("OpenDTU-OnBattery"); + httpClient.setConnectTimeout(timeout); + httpClient.setTimeout(timeout); + httpClient.addHeader("Content-Type", "application/json"); + httpClient.addHeader("Accept", "application/json"); + + if (strlen(httpHeader) > 0) { + httpClient.addHeader(httpHeader, httpValue); + } + + int httpCode = httpClient.GET(); + + + if (httpCode == HTTP_CODE_OK) { + if (httpClient.getSize() > (responseSize - 1)) { + snprintf_P(error, errorSize, "Response too large!"); + } else { + snprintf(response, responseSize, httpClient.getString().c_str()); + } + } else if (httpCode <= 0) { + snprintf_P(error, errorSize, "Error: %s", httpClient.errorToString(httpCode).c_str()); + } else if (httpCode != HTTP_CODE_OK) { + snprintf_P(error, errorSize, "Bad HTTP code: %d", httpCode); + } + + httpClient.end(); + delete wifiClient; + + if (error[0] != '\0') { + return false; + } + + return true; +} + +float HttpPowerMeterClass::getFloatValueByJsonPath(const char* jsonString, const char* jsonPath, float& value) +{ + FirebaseJson firebaseJson; + firebaseJson.setJsonData(jsonString); + + FirebaseJsonData firebaseJsonResult; + if (!firebaseJson.get(firebaseJsonResult, jsonPath)) { + return false; + } + + value = firebaseJsonResult.to(); + + firebaseJson.clear(); + + return true; +} + +HttpPowerMeterClass HttpPowerMeter; diff --git a/src/PowerLimiter.cpp b/src/PowerLimiter.cpp index b9b5b171d..539bc11b0 100644 --- a/src/PowerLimiter.cpp +++ b/src/PowerLimiter.cpp @@ -175,7 +175,8 @@ int32_t PowerLimiterClass::calcPowerLimit(std::shared_ptr inve MessageOutput.printf("[PowerLimiterClass::loop] victronChargePower: %d, efficiency: %.2f, consumeSolarPowerOnly: %s, powerConsumption: %d \r\n", victronChargePower, efficency, consumeSolarPowerOnly ? "true" : "false", newPowerLimit); - + + // Safety check: Are the power meter values not too old? if (millis() - PowerMeter.getLastPowerMeterUpdate() < (30 * 1000)) { if (config.PowerLimiter_IsInverterBehindPowerMeter) { // If the inverter the behind the power meter (part of measurement), diff --git a/src/PowerMeter.cpp b/src/PowerMeter.cpp index a27297e8a..6bfe7e08d 100644 --- a/src/PowerMeter.cpp +++ b/src/PowerMeter.cpp @@ -4,6 +4,7 @@ */ #include "PowerMeter.h" #include "Configuration.h" +#include "HttpPowerMeter.h" #include "MqttSettings.h" #include "NetworkSettings.h" #include "SDM.h" @@ -23,6 +24,7 @@ void PowerMeterClass::init() using std::placeholders::_5; using std::placeholders::_6; + _lastPowerMeterCheck = 0; _lastPowerMeterUpdate = 0; CONFIG_T& config = Configuration.get(); @@ -44,43 +46,48 @@ void PowerMeterClass::init() mqttInitDone = true; sdm.begin(); + HttpPowerMeter.init(); } void PowerMeterClass::onMqttMessage(const espMqttClientTypes::MessageProperties& properties, const char* topic, const uint8_t* payload, size_t len, size_t index, size_t total) { CONFIG_T& config = Configuration.get(); - if(config.PowerMeter_Enabled && config.PowerMeter_Source == 0){ + if (config.PowerMeter_Enabled && config.PowerMeter_Source != SOURCE_MQTT) { + return; + } - if (strcmp(topic, config.PowerMeter_MqttTopicPowerMeter1) == 0) { - _powerMeter1Power = std::stof(std::string(reinterpret_cast(payload), (unsigned int)len)); - } + if (strcmp(topic, config.PowerMeter_MqttTopicPowerMeter1) == 0) { + _powerMeter1Power = std::stof(std::string(reinterpret_cast(payload), (unsigned int)len)); + } - if (strcmp(topic, config.PowerMeter_MqttTopicPowerMeter2) == 0) { - _powerMeter2Power = std::stof(std::string(reinterpret_cast(payload), (unsigned int)len)); - } + if (strcmp(topic, config.PowerMeter_MqttTopicPowerMeter2) == 0) { + _powerMeter2Power = std::stof(std::string(reinterpret_cast(payload), (unsigned int)len)); + } - if (strcmp(topic, config.PowerMeter_MqttTopicPowerMeter3) == 0) { - _powerMeter3Power = std::stof(std::string(reinterpret_cast(payload), (unsigned int)len)); - } - - MessageOutput.printf("PowerMeterClass: TotalPower: %5.2f\n", getPowerTotal()); + if (strcmp(topic, config.PowerMeter_MqttTopicPowerMeter3) == 0) { + _powerMeter3Power = std::stof(std::string(reinterpret_cast(payload), (unsigned int)len)); } + MessageOutput.printf("PowerMeterClass: TotalPower: %5.2f\n", getPowerTotal()); + _lastPowerMeterUpdate = millis(); } -float PowerMeterClass::getPowerTotal(){ +float PowerMeterClass::getPowerTotal() +{ return _powerMeter1Power + _powerMeter2Power + _powerMeter3Power; } -uint32_t PowerMeterClass::getLastPowerMeterUpdate(){ +uint32_t PowerMeterClass::getLastPowerMeterUpdate() +{ return _lastPowerMeterUpdate; } -void PowerMeterClass::mqtt(){ - if (!MqttSettings.getConnected()){ +void PowerMeterClass::mqtt() +{ + if (!MqttSettings.getConnected()) { return; - }else{ + } else { String topic = "powermeter"; MqttSettings.publish(topic + "/power1", String(_powerMeter1Power)); MqttSettings.publish(topic + "/power2", String(_powerMeter2Power)); @@ -98,33 +105,47 @@ void PowerMeterClass::loop() { CONFIG_T& config = Configuration.get(); - if(config.PowerMeter_Enabled && millis() - _lastPowerMeterUpdate >= (config.PowerMeter_Interval * 1000)){ - uint8_t _address = config.PowerMeter_SdmAddress; - if(config.PowerMeter_Source == 1){ - _powerMeter1Power = static_cast(sdm.readVal(SDM_PHASE_1_POWER, _address)); - _powerMeter2Power = 0.0; - _powerMeter3Power = 0.0; - _powerMeter1Voltage = static_cast(sdm.readVal(SDM_PHASE_1_VOLTAGE, _address)); - _powerMeter2Voltage = 0.0; - _powerMeter3Voltage = 0.0; - _PowerMeterImport = static_cast(sdm.readVal(SDM_IMPORT_ACTIVE_ENERGY, _address)); - _PowerMeterExport = static_cast(sdm.readVal(SDM_EXPORT_ACTIVE_ENERGY, _address)); - } - if(config.PowerMeter_Source == 2){ - _powerMeter1Power = static_cast(sdm.readVal(SDM_PHASE_1_POWER, _address)); - _powerMeter2Power = static_cast(sdm.readVal(SDM_PHASE_2_POWER, _address)); - _powerMeter3Power = static_cast(sdm.readVal(SDM_PHASE_3_POWER, _address)); - _powerMeter1Voltage = static_cast(sdm.readVal(SDM_PHASE_1_VOLTAGE, _address)); - _powerMeter2Voltage = static_cast(sdm.readVal(SDM_PHASE_2_VOLTAGE, _address)); - _powerMeter3Voltage = static_cast(sdm.readVal(SDM_PHASE_3_VOLTAGE, _address)); - _PowerMeterImport = static_cast(sdm.readVal(SDM_IMPORT_ACTIVE_ENERGY, _address)); - _PowerMeterExport = static_cast(sdm.readVal(SDM_EXPORT_ACTIVE_ENERGY, _address)); - } - - MessageOutput.printf("PowerMeterClass: TotalPower: %5.2f\n", getPowerTotal()); - - mqtt(); + if (!config.PowerMeter_Enabled + || (millis() - _lastPowerMeterCheck) < (config.PowerMeter_Interval * 1000)) { + return; + } + uint8_t _address = config.PowerMeter_SdmAddress; + + if (config.PowerMeter_Source== SOURCE_SDM1PH) { + _powerMeter1Power = static_cast(sdm.readVal(SDM_PHASE_1_POWER, _address)); + _powerMeter2Power = 0.0; + _powerMeter3Power = 0.0; + _powerMeter1Voltage = static_cast(sdm.readVal(SDM_PHASE_1_VOLTAGE, _address)); + _powerMeter2Voltage = 0.0; + _powerMeter3Voltage = 0.0; + _PowerMeterImport = static_cast(sdm.readVal(SDM_IMPORT_ACTIVE_ENERGY, _address)); + _PowerMeterExport = static_cast(sdm.readVal(SDM_EXPORT_ACTIVE_ENERGY, _address)); + _lastPowerMeterUpdate = millis(); + } + else if (config.PowerMeter_Source == SOURCE_SDM3PH) { + _powerMeter1Power = static_cast(sdm.readVal(SDM_PHASE_1_POWER, _address)); + _powerMeter2Power = static_cast(sdm.readVal(SDM_PHASE_2_POWER, _address)); + _powerMeter3Power = static_cast(sdm.readVal(SDM_PHASE_3_POWER, _address)); + _powerMeter1Voltage = static_cast(sdm.readVal(SDM_PHASE_1_VOLTAGE, _address)); + _powerMeter2Voltage = static_cast(sdm.readVal(SDM_PHASE_2_VOLTAGE, _address)); + _powerMeter3Voltage = static_cast(sdm.readVal(SDM_PHASE_3_VOLTAGE, _address)); + _PowerMeterImport = static_cast(sdm.readVal(SDM_IMPORT_ACTIVE_ENERGY, _address)); + _PowerMeterExport = static_cast(sdm.readVal(SDM_EXPORT_ACTIVE_ENERGY, _address)); _lastPowerMeterUpdate = millis(); } + else if (config.PowerMeter_Source == SOURCE_HTTP) { + if (HttpPowerMeter.updateValues()) { + _powerMeter1Power = HttpPowerMeter.getPower(1); + _powerMeter2Power = HttpPowerMeter.getPower(2); + _powerMeter3Power = HttpPowerMeter.getPower(3); + _lastPowerMeterUpdate = millis(); + } + } + + MessageOutput.printf("PowerMeterClass: TotalPower: %5.2f\n", getPowerTotal()); + + mqtt(); + + _lastPowerMeterCheck = millis(); } diff --git a/src/WebApi_powermeter.cpp b/src/WebApi_powermeter.cpp index 9d8bd1fa8..864ceda8b 100644 --- a/src/WebApi_powermeter.cpp +++ b/src/WebApi_powermeter.cpp @@ -12,6 +12,7 @@ #include "MqttSettings.h" #include "PowerLimiter.h" #include "PowerMeter.h" +#include "HttpPowerMeter.h" #include "WebApi.h" #include "helper.h" @@ -24,6 +25,7 @@ void WebApiPowerMeterClass::init(AsyncWebServer* server) _server->on("/api/powermeter/status", HTTP_GET, std::bind(&WebApiPowerMeterClass::onStatus, this, _1)); _server->on("/api/powermeter/config", HTTP_GET, std::bind(&WebApiPowerMeterClass::onAdminGet, this, _1)); _server->on("/api/powermeter/config", HTTP_POST, std::bind(&WebApiPowerMeterClass::onAdminPost, this, _1)); + _server->on("/api/powermeter/testhttprequest", HTTP_POST, std::bind(&WebApiPowerMeterClass::onTestHttpRequest, this, _1)); } void WebApiPowerMeterClass::loop() @@ -44,6 +46,21 @@ void WebApiPowerMeterClass::onStatus(AsyncWebServerRequest* request) root[F("mqtt_topic_powermeter_3")] = config.PowerMeter_MqttTopicPowerMeter3; root[F("sdmbaudrate")] = config.PowerMeter_SdmBaudrate; root[F("sdmaddress")] = config.PowerMeter_SdmAddress; + root[F("http_individual_requests")] = config.PowerMeter_HttpIndividualRequests; + + JsonArray httpPhases = root.createNestedArray(F("http_phases")); + + for (uint8_t i = 0; i < POWERMETER_MAX_PHASES; i++) { + JsonObject phaseObject = httpPhases.createNestedObject(); + + phaseObject[F("index")] = i + 1; + phaseObject[F("enabled")] = config.Powermeter_Http_Phase[i].Enabled; + phaseObject[F("url")] = String(config.Powermeter_Http_Phase[i].Url); + phaseObject[F("header_key")] = String(config.Powermeter_Http_Phase[i].HeaderKey); + phaseObject[F("header_value")] = String(config.Powermeter_Http_Phase[i].HeaderValue); + phaseObject[F("json_path")] = String(config.Powermeter_Http_Phase[i].JsonPath); + phaseObject[F("timeout")] = config.Powermeter_Http_Phase[i].Timeout; + } response->setLength(); request->send(response); @@ -77,14 +94,14 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request) String json = request->getParam("data", true)->value(); - if (json.length() > 1024) { + if (json.length() > 4096) { retMsg[F("message")] = F("Data too large!"); response->setLength(); request->send(response); return; } - DynamicJsonDocument root(1024); + DynamicJsonDocument root(4096); DeserializationError error = deserializeJson(root, json); if (error) { @@ -101,6 +118,44 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request) return; } + if (root[F("source")].as() == PowerMeter.SOURCE_HTTP) { + JsonArray http_phases = root[F("http_phases")]; + for (uint8_t i = 0; i < http_phases.size(); i++) { + JsonObject phase = http_phases[i].as(); + + if (i > 0 && !phase[F("enabled")].as()) { + continue; + } + + if (i == 0 || phase[F("http_individual_requests")].as()) { + if (!phase.containsKey("url") + || (!phase[F("url")].as().startsWith("http://") + && !phase[F("url")].as().startsWith("https://"))) { + retMsg[F("message")] = F("URL must either start with http:// or https://!"); + response->setLength(); + request->send(response); + return; + } + + if (!phase.containsKey("timeout") + || phase[F("timeout")].as() <= 0) { + retMsg[F("message")] = F("Timeout must be greater than 0 ms!"); + response->setLength(); + request->send(response); + return; + } + } + + if (!phase.containsKey("json_path") + || phase[F("json_path")].as().length() == 0) { + retMsg[F("message")] = F("Json path must not be empty!"); + response->setLength(); + request->send(response); + return; + } + } + } + CONFIG_T& config = Configuration.get(); config.PowerMeter_Enabled = root[F("enabled")].as(); config.PowerMeter_Source = root[F("source")].as(); @@ -110,6 +165,20 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request) strlcpy(config.PowerMeter_MqttTopicPowerMeter3, root[F("mqtt_topic_powermeter_3")].as().c_str(), sizeof(config.PowerMeter_MqttTopicPowerMeter3)); config.PowerMeter_SdmBaudrate = root[F("sdmbaudrate")].as(); config.PowerMeter_SdmAddress = root[F("sdmaddress")].as(); + config.PowerMeter_HttpIndividualRequests = root[F("http_individual_requests")].as(); + + JsonArray http_phases = root[F("http_phases")]; + for (uint8_t i = 0; i < http_phases.size(); i++) { + JsonObject phase = http_phases[i].as(); + + config.Powermeter_Http_Phase[i].Enabled = (i == 0 ? true : phase[F("enabled")].as()); + strlcpy(config.Powermeter_Http_Phase[i].Url, phase[F("url")].as().c_str(), sizeof(config.Powermeter_Http_Phase[i].Url)); + strlcpy(config.Powermeter_Http_Phase[i].HeaderKey, phase[F("header_key")].as().c_str(), sizeof(config.Powermeter_Http_Phase[i].HeaderKey)); + strlcpy(config.Powermeter_Http_Phase[i].HeaderValue, phase[F("header_value")].as().c_str(), sizeof(config.Powermeter_Http_Phase[i].HeaderValue)); + config.Powermeter_Http_Phase[i].Timeout = phase[F("timeout")].as(); + strlcpy(config.Powermeter_Http_Phase[i].JsonPath, phase[F("json_path")].as().c_str(), sizeof(config.Powermeter_Http_Phase[i].JsonPath)); + } + Configuration.write(); retMsg[F("type")] = F("success"); @@ -123,3 +192,72 @@ void WebApiPowerMeterClass::onAdminPost(AsyncWebServerRequest* request) yield(); ESP.restart(); } + +void WebApiPowerMeterClass::onTestHttpRequest(AsyncWebServerRequest* request) +{ + if (!WebApi.checkCredentials(request)) { + return; + } + + AsyncJsonResponse* asyncJsonResponse = new AsyncJsonResponse(); + JsonObject retMsg = asyncJsonResponse->getRoot(); + retMsg[F("type")] = F("warning"); + + if (!request->hasParam("data", true)) { + retMsg[F("message")] = F("No values found!"); + asyncJsonResponse->setLength(); + request->send(asyncJsonResponse); + return; + } + + String json = request->getParam("data", true)->value(); + + if (json.length() > 2048) { + retMsg[F("message")] = F("Data too large!"); + asyncJsonResponse->setLength(); + request->send(asyncJsonResponse); + return; + } + + DynamicJsonDocument root(2048); + DeserializationError error = deserializeJson(root, json); + + if (error) { + retMsg[F("message")] = F("Failed to parse data!"); + asyncJsonResponse->setLength(); + request->send(asyncJsonResponse); + return; + } + + if (!root.containsKey("url") || !root.containsKey("header_key") || !root.containsKey("header_value") + || !root.containsKey("timeout") || !root.containsKey("json_path")) { + retMsg[F("message")] = F("Missing fields!"); + asyncJsonResponse->setLength(); + request->send(asyncJsonResponse); + return; + } + + char powerMeterResponse[2000], + errorMessage[256]; + char response[200]; + + if (HttpPowerMeter.httpRequest(root[F("url")].as().c_str(), root[F("header_key")].as().c_str(), + root[F("header_value")].as().c_str(), root[F("timeout")].as(), + powerMeterResponse, sizeof(powerMeterResponse), errorMessage, sizeof(errorMessage))) { + float power; + + if (HttpPowerMeter.getFloatValueByJsonPath(powerMeterResponse, + root[F("json_path")].as().c_str(), power)) { + retMsg[F("type")] = F("success"); + snprintf_P(response, sizeof(response), "Success! Power: %5.2fW", power); + } else { + snprintf_P(response, sizeof(response), "Error: Could not find value for JSON path!"); + } + } else { + snprintf_P(response, sizeof(response), errorMessage); + } + + retMsg[F("message")] = F(response); + asyncJsonResponse->setLength(); + request->send(asyncJsonResponse); +} diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index 99d812902..0886f193f 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -463,13 +463,28 @@ "typeMQTT": "MQTT", "typeSDM1ph": "SDM 1 phase (SDM120/220/230)", "typeSDM3ph": "SDM 3 phase (SDM72/630)", + "typeHTTP": "HTTP(S) + JSON", "MqttTopicPowerMeter1": "MQTT topic - Stromzähler #1", "MqttTopicPowerMeter2": "MQTT topic - Stromzähler #2 (Optional)", "MqttTopicPowerMeter3": "MQTT topic - Stromzähler #3 (Optional)", "SDM": "SDM-Stromzähler Konfiguration", "sdmbaudrate": "Baudrate", "sdmaddress": "Modbus Adresse", - "Save": "@:dtuadmin.Save" + "Save": "@:dtuadmin.Save", + "HTTP": "HTTP(S) + JSON - Allgemeine Konfiguration", + "httpIndividualRequests": "Individuelle HTTP requests pro Phase", + "httpUrlDescription": "Die URL muss mit http:// oder https:// beginnen. Achtung: Ein Überprüfung von SSL Server Zertifikaten ist nicht implementiert (MITM-Attacken sind möglich)! Beispiele gibt es unten.", + + "httpPhase": "HTTP(S) + JSON Konfiguration - Phase {phaseNumber}", + "httpEnabled": "Phase aktiviert", + "httpUrl": "URL", + "httpHeaderKey": "Optional: HTTP request header - Key", + "httpHeaderKeyDescription": "Ein individueller HTTP request header kann hier definiert werden. Das kann z.B. verwendet werden um einen eigenen Authorization header mitzugeben.", + "httpHeaderValue": "Optional: HTTP request header - Wert", + "httpJsonPath": "JSON Pfad", + "httpJsonPathDescription": "JSON Pfad um den Leistungswert zu finden. Es verwendet die Selektions-Syntax von mobizt/FirebaseJson. Beispiele gibt es unten.", + "httpTimeout": "Timeout", + "testHttpRequest": "Testen" }, "powerlimiteradmin": { "PowerLimiterSettings": "Power Limiter Einstellungen", diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index fba77ae5c..84bc0f046 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -464,13 +464,28 @@ "typeMQTT": "MQTT", "typeSDM1ph": "SDM 1 phase (SDM120/220/230)", "typeSDM3ph": "SDM 3 phase (SDM72/630)", + "typeHTTP": "HTTP(s) + JSON", "MqttTopicPowerMeter1": "MQTT topic - Power meter #1", "MqttTopicPowerMeter2": "MQTT topic - Power meter #2", "MqttTopicPowerMeter3": "MQTT topic - Power meter #3", "SDM": "SDM-Power Meter Parameter", "sdmbaudrate": "Baudrate", "sdmaddress": "Modbus Address", - "Save": "@:dtuadmin.Save" + "Save": "@:dtuadmin.Save", + "HTTP": "HTTP(S) + Json - General configuration", + "httpIndividualRequests": "Individual HTTP requests per phase", + "httpPhase": "HTTP(S) + Json configuration - Phase {phaseNumber}", + "httpEnabled": "Phase enabled", + "httpUrl": "URL", + "httpUrlDescription": "URL must start with http:// or https://. Warning: SSL server certificate check is not implemented (MITM attacks are possible)! See below for some examples.", + "httpHeaderKey": "Optional: HTTP request header - Key", + "httpHeaderKeyDescription": "A custom HTTP request header can be defined. Might be useful if you have to send something like a custom Authorization header.", + "httpHeaderValue": "Optional: HTTP request header - Value", + "httpJsonPath": "Json path", + "httpJsonPathDescription": "JSON path to find the power value in the response. This uses the JSON path query syntax from mobizt/FirebaseJson. See below for some examples.", + "httpTimeout": "Timeout", + "testHttpRequest": "Run test", + "milliSeconds": "ms" }, "powerlimiteradmin": { "PowerLimiterSettings": "Power Limiter Settings", diff --git a/webapp/src/types/PowerMeterConfig.ts b/webapp/src/types/PowerMeterConfig.ts index 325e51175..612c84321 100644 --- a/webapp/src/types/PowerMeterConfig.ts +++ b/webapp/src/types/PowerMeterConfig.ts @@ -1,3 +1,13 @@ +export interface PowerMeterHttpPhaseConfig { + index: number; + enabled: boolean; + url: string; + header_key: string; + header_value: string; + json_path: string; + timeout: number; +}; + export interface PowerMeterConfig { enabled: boolean; source: number; @@ -7,4 +17,6 @@ export interface PowerMeterConfig { mqtt_topic_powermeter_3: string; sdmbaudrate: number; sdmaddress: number; + http_individual_requests: boolean; + http_phases: Array; } diff --git a/webapp/src/views/PowerMeterAdminView.vue b/webapp/src/views/PowerMeterAdminView.vue index 20b68ed3d..ed6d0ec97 100644 --- a/webapp/src/views/PowerMeterAdminView.vue +++ b/webapp/src/views/PowerMeterAdminView.vue @@ -1,24 +1,22 @@ @@ -100,19 +184,37 @@ import { defineComponent } from 'vue'; import BasePage from '@/components/BasePage.vue'; import BootstrapAlert from "@/components/BootstrapAlert.vue"; +import CardElement from '@/components/CardElement.vue'; +import InputElement from '@/components/InputElement.vue'; import { handleResponse, authHeader } from '@/utils/authentication'; -import type { PowerMeterConfig } from "@/types/PowerMeterConfig"; +import { BIconInfoCircle } from 'bootstrap-icons-vue'; +import type { PowerMeterHttpPhaseConfig, PowerMeterConfig } from "@/types/PowerMeterConfig"; export default defineComponent({ components: { BasePage, BootstrapAlert, + CardElement, + InputElement, + BIconInfoCircle, }, data() { + const people: { name: string; age: number; }[] = [ + { + age: 27, + name: 'Tim' + }, + { + age: 28, + name: 'Bob' + } +]; + return { dataLoading: true, powerMeterConfigList: {} as PowerMeterConfig, powerMeterSourceList: [ + { key: 3, value: this.$t('powermeteradmin.typeHTTP') }, { key: 0, value: this.$t('powermeteradmin.typeMQTT') }, { key: 1, value: this.$t('powermeteradmin.typeSDM1ph') }, { key: 2, value: this.$t('powermeteradmin.typeSDM3ph') }, @@ -120,6 +222,7 @@ export default defineComponent({ alertMessage: "", alertType: "info", showAlert: false, + testHttpRequestAlert: <{ message: string; type: string; show: boolean; }[]> [ ], }; }, created() { @@ -133,6 +236,23 @@ export default defineComponent({ .then((data) => { this.powerMeterConfigList = data; this.dataLoading = false; + + type MyType = { + id: number; + name: string; + } + + type MyGroupType = { + [key:string]: MyType; + } + + for (var i = 0; i < this.powerMeterConfigList.http_phases.length; i++) { + this.testHttpRequestAlert.push({ + message: "", + type: "", + show: false, + }); + } }); }, savePowerMeterConfig(e: Event) { @@ -152,9 +272,46 @@ export default defineComponent({ this.alertMessage = response.message; this.alertType = response.type; this.showAlert = true; + window.scrollTo(0, 0); } ); }, + testHttpRequest(index: number) { + var phaseConfig:PowerMeterHttpPhaseConfig; + + if (this.powerMeterConfigList.http_individual_requests) { + phaseConfig = this.powerMeterConfigList.http_phases[index]; + } else { + phaseConfig = { ...this.powerMeterConfigList.http_phases[0] }; + phaseConfig.index = this.powerMeterConfigList.http_phases[index].index; + phaseConfig.json_path = this.powerMeterConfigList.http_phases[index].json_path; + } + + this.testHttpRequestAlert[index] = { + message: "Sending HTTP request...", + type: "info", + show: true, + }; + + const formData = new FormData(); + formData.append("data", JSON.stringify(phaseConfig)); + + fetch("/api/powermeter/testhttprequest", { + method: "POST", + headers: authHeader(), + body: formData, + }) + .then((response) => handleResponse(response, this.$emitter, this.$router)) + .then( + (response) => { + this.testHttpRequestAlert[index] = { + message: response.message, + type: response.type, + show: true, + }; + } + ) + }, }, }); From dfeafb26375c77e908aa6ed52404f3925015bbc4 Mon Sep 17 00:00:00 2001 From: berni2288 Date: Sun, 2 Apr 2023 15:20:10 +0200 Subject: [PATCH 2/5] Enhance HttpPowerMeter examples and URl description --- webapp/src/locales/de.json | 3 +-- webapp/src/locales/en.json | 2 +- webapp/src/views/PowerMeterAdminView.vue | 3 ++- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index 0886f193f..54b00f1d8 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -473,8 +473,7 @@ "Save": "@:dtuadmin.Save", "HTTP": "HTTP(S) + JSON - Allgemeine Konfiguration", "httpIndividualRequests": "Individuelle HTTP requests pro Phase", - "httpUrlDescription": "Die URL muss mit http:// oder https:// beginnen. Achtung: Ein Überprüfung von SSL Server Zertifikaten ist nicht implementiert (MITM-Attacken sind möglich)! Beispiele gibt es unten.", - + "httpUrlDescription": "Die URL muss mit http:// oder https:// beginnen. Manche Zeichen wie Leerzeichen und = müssen mit URL-Kodierung kodiert werden (%xx). Achtung: Ein Überprüfung von SSL Server Zertifikaten ist nicht implementiert (MITM-Attacken sind möglich)! Beispiele gibt es unten.", "httpPhase": "HTTP(S) + JSON Konfiguration - Phase {phaseNumber}", "httpEnabled": "Phase aktiviert", "httpUrl": "URL", diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 84bc0f046..6e660148d 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -477,7 +477,7 @@ "httpPhase": "HTTP(S) + Json configuration - Phase {phaseNumber}", "httpEnabled": "Phase enabled", "httpUrl": "URL", - "httpUrlDescription": "URL must start with http:// or https://. Warning: SSL server certificate check is not implemented (MITM attacks are possible)! See below for some examples.", + "httpUrlDescription": "URL must start with http:// or https://. Some characters like spaces and = have to be encoded with URL encoding (%xx). Warning: SSL server certificate check is not implemented (MITM attacks are possible)! See below for some examples.", "httpHeaderKey": "Optional: HTTP request header - Key", "httpHeaderKeyDescription": "A custom HTTP request header can be defined. Might be useful if you have to send something like a custom Authorization header.", "httpHeaderValue": "Optional: HTTP request header - Value", diff --git a/webapp/src/views/PowerMeterAdminView.vue b/webapp/src/views/PowerMeterAdminView.vue index ed6d0ec97..c227afd85 100644 --- a/webapp/src/views/PowerMeterAdminView.vue +++ b/webapp/src/views/PowerMeterAdminView.vue @@ -165,12 +165,13 @@
  • http://admin:secret@shelly3em.home/status
  • https://admin:secret@shelly3em.home/status
  • +
  • http://tasmota-123.home/cm?cmnd=status%208
  • http://12.34.56.78/emeter/0

JSON path examples:

    -
  • total_power - { othervalue: "blah", "total_power": 123.4 }
  • +
  • total_power - { "othervalue": "blah", "total_power": 123.4 }
  • testarray/[2]/myvalue - { "testarray": [ {}, { "power": 123.4 } ] }
From 8f3f9acd6767b2ca010f5592fcb8b7e6122277e6 Mon Sep 17 00:00:00 2001 From: berni2288 Date: Sun, 2 Apr 2023 15:57:00 +0200 Subject: [PATCH 3/5] HttpPowerMeter: Add more response size error output --- src/HttpPowerMeter.cpp | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/HttpPowerMeter.cpp b/src/HttpPowerMeter.cpp index 0841f3ad9..c51e99c0d 100644 --- a/src/HttpPowerMeter.cpp +++ b/src/HttpPowerMeter.cpp @@ -87,7 +87,8 @@ bool HttpPowerMeterClass::httpRequest(const char* url, const char* httpHeader, c if (httpCode == HTTP_CODE_OK) { if (httpClient.getSize() > (responseSize - 1)) { - snprintf_P(error, errorSize, "Response too large!"); + snprintf_P(error, errorSize, "Response too large! Response length: %d Body: %s", + httpClient.getSize(), httpClient.getString().c_str()); } else { snprintf(response, responseSize, httpClient.getString().c_str()); } From 2ac487bbf23a0f53ad56521fc3de364adccb76d9 Mon Sep 17 00:00:00 2001 From: berni2288 Date: Mon, 3 Apr 2023 06:21:01 +0200 Subject: [PATCH 4/5] HttpPowerMeter: Fix error check "Response too large" The Content-Length header is not always returned by a web server --- include/HttpPowerMeter.h | 2 +- src/HttpPowerMeter.cpp | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/include/HttpPowerMeter.h b/include/HttpPowerMeter.h index c704799d5..d80101547 100644 --- a/include/HttpPowerMeter.h +++ b/include/HttpPowerMeter.h @@ -9,7 +9,7 @@ class HttpPowerMeterClass { bool updateValues(); float getPower(int8_t phase); bool httpRequest(const char* url, const char* httpHeader, const char* httpValue, uint32_t timeout, - char* response, uint32_t responseSize, char* error, uint32_t errorSize); + char* response, size_t responseSize, char* error, size_t errorSize); float getFloatValueByJsonPath(const char* jsonString, const char* jsonPath, float &value); private: diff --git a/src/HttpPowerMeter.cpp b/src/HttpPowerMeter.cpp index c51e99c0d..4d1d44466 100644 --- a/src/HttpPowerMeter.cpp +++ b/src/HttpPowerMeter.cpp @@ -50,7 +50,7 @@ bool HttpPowerMeterClass::updateValues() } bool HttpPowerMeterClass::httpRequest(const char* url, const char* httpHeader, const char* httpValue, uint32_t timeout, - char* response, uint32_t responseSize, char* error, uint32_t errorSize) + char* response, size_t responseSize, char* error, size_t errorSize) { WiFiClient* wifiClient = NULL; HTTPClient httpClient; @@ -86,11 +86,13 @@ bool HttpPowerMeterClass::httpRequest(const char* url, const char* httpHeader, c if (httpCode == HTTP_CODE_OK) { - if (httpClient.getSize() > (responseSize - 1)) { - snprintf_P(error, errorSize, "Response too large! Response length: %d Body: %s", - httpClient.getSize(), httpClient.getString().c_str()); + String responseBody = httpClient.getString(); + + if (responseBody.length() > (responseSize - 1)) { + snprintf_P(error, errorSize, "Response too large! Response length: %d Body start: %s", + httpClient.getSize(), responseBody.c_str()); } else { - snprintf(response, responseSize, httpClient.getString().c_str()); + snprintf(response, responseSize, responseBody.c_str()); } } else if (httpCode <= 0) { snprintf_P(error, errorSize, "Error: %s", httpClient.errorToString(httpCode).c_str()); From 92b75579b940b00bc66a24d357f86c6056fe8584 Mon Sep 17 00:00:00 2001 From: berni2288 Date: Mon, 3 Apr 2023 21:34:47 +0200 Subject: [PATCH 5/5] MessageOutput.printf needs \r\n instead of \n --- src/HttpPowerMeter.cpp | 4 ++-- src/PowerMeter.cpp | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/HttpPowerMeter.cpp b/src/HttpPowerMeter.cpp index 4d1d44466..5bd28d3ba 100644 --- a/src/HttpPowerMeter.cpp +++ b/src/HttpPowerMeter.cpp @@ -34,14 +34,14 @@ bool HttpPowerMeterClass::updateValues() if (i == 0 || config.PowerMeter_HttpIndividualRequests) { if (!httpRequest(phaseConfig.Url, phaseConfig.HeaderKey, phaseConfig.HeaderValue, phaseConfig.Timeout, response, sizeof(response), errorMessage, sizeof(errorMessage))) { - MessageOutput.printf("[HttpPowerMeter] Getting the power of phase %d failed. Error: %s\n", + MessageOutput.printf("[HttpPowerMeter] Getting the power of phase %d failed. Error: %s\r\n", i + 1, errorMessage); success = false; } } if (!getFloatValueByJsonPath(response, phaseConfig.JsonPath, power[i])) { - MessageOutput.printf("[HttpPowerMeter] Couldn't find a value with Json query \"%s\"\n", phaseConfig.JsonPath); + MessageOutput.printf("[HttpPowerMeter] Couldn't find a value with Json query \"%s\"\r\n", phaseConfig.JsonPath); success = false; } } diff --git a/src/PowerMeter.cpp b/src/PowerMeter.cpp index 6bfe7e08d..da2fc2d3b 100644 --- a/src/PowerMeter.cpp +++ b/src/PowerMeter.cpp @@ -68,7 +68,7 @@ void PowerMeterClass::onMqttMessage(const espMqttClientTypes::MessageProperties& _powerMeter3Power = std::stof(std::string(reinterpret_cast(payload), (unsigned int)len)); } - MessageOutput.printf("PowerMeterClass: TotalPower: %5.2f\n", getPowerTotal()); + MessageOutput.printf("PowerMeterClass: TotalPower: %5.2f\r\n", getPowerTotal()); _lastPowerMeterUpdate = millis(); } @@ -143,7 +143,7 @@ void PowerMeterClass::loop() } } - MessageOutput.printf("PowerMeterClass: TotalPower: %5.2f\n", getPowerTotal()); + MessageOutput.printf("PowerMeterClass: TotalPower: %5.2f\r\n", getPowerTotal()); mqtt();