From a7b2d727f8c7b2d6887cf71b4a39f8e01552c869 Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Wed, 5 Apr 2023 21:20:28 +0200 Subject: [PATCH 1/9] Fix: Show version number in web interface instead of git hash (if available) see https://github.com/actions/checkout/issues/701 for further info --- .github/workflows/build.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 84959fbf..4fa430ea 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,6 +52,9 @@ jobs: steps: - uses: actions/checkout@v3 + - name: Get tags + run: git fetch --force --tags origin + - name: Cache pip uses: actions/cache@v3 with: From 477eb6cfd61c14deb9ec44c3e84db00379958eac Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Thu, 6 Apr 2023 22:36:33 +0200 Subject: [PATCH 2/9] Feature: Link to release page instead to commits page in Firmware Info The Firmware Version link now referes to the release page if the given hash is a tag. It referes to the commits page if it's really a hash. (Implements #778) --- webapp/src/components/FirmwareInfo.vue | 8 +++++++- webapp/src/types/SystemStatus.ts | 1 + webapp/src/views/SystemInfoView.vue | 4 +++- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/webapp/src/components/FirmwareInfo.vue b/webapp/src/components/FirmwareInfo.vue index 9eda8935..afc03cfd 100644 --- a/webapp/src/components/FirmwareInfo.vue +++ b/webapp/src/components/FirmwareInfo.vue @@ -17,7 +17,7 @@ {{ $t('firmwareinfo.FirmwareVersion') }} - {{ systemStatus.git_hash }} @@ -72,6 +72,12 @@ export default defineComponent({ return timestampToString(value, true); }; }, + versionInfoUrl(): string { + if (this.systemStatus.git_is_hash) { + return 'https://github.com/tbnobody/OpenDTU/commits/' + this.systemStatus.git_hash; + } + return 'https://github.com/tbnobody/OpenDTU/releases/tag/' + this.systemStatus.git_hash; + } }, }); diff --git a/webapp/src/types/SystemStatus.ts b/webapp/src/types/SystemStatus.ts index 9e6728e5..6be634cd 100644 --- a/webapp/src/types/SystemStatus.ts +++ b/webapp/src/types/SystemStatus.ts @@ -9,6 +9,7 @@ export interface SystemStatus { sdkversion: string; config_version: string; git_hash: string; + git_is_hash: boolean; resetreason_0: string; resetreason_1: string; cfgsavecount: number; diff --git a/webapp/src/views/SystemInfoView.vue b/webapp/src/views/SystemInfoView.vue index d34d6ce7..fb73170a 100644 --- a/webapp/src/views/SystemInfoView.vue +++ b/webapp/src/views/SystemInfoView.vue @@ -51,11 +51,13 @@ export default defineComponent({ }, getUpdateInfo() { // If the left char is a "g" the value is the git hash (remove the "g") - this.systemDataList.git_hash = this.systemDataList.git_hash?.substring(0, 1) == 'g' ? this.systemDataList.git_hash?.substring(1) : this.systemDataList.git_hash; + this.systemDataList.git_is_hash = this.systemDataList.git_hash?.substring(0, 1) == 'g'; + this.systemDataList.git_hash = this.systemDataList.git_is_hash ? this.systemDataList.git_hash?.substring(1) : this.systemDataList.git_hash; // Handle format "v0.1-5-gabcdefh" if (this.systemDataList.git_hash.lastIndexOf("-") >= 0) { this.systemDataList.git_hash = this.systemDataList.git_hash.substring(this.systemDataList.git_hash.lastIndexOf("-") + 2) + this.systemDataList.git_is_hash = true; } const fetchUrl = "https://api.github.com/repos/tbnobody/OpenDTU/compare/" From ee5fe9441e4e15836b4b9afa66bac62c65c3d6cf Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Thu, 6 Apr 2023 22:38:42 +0200 Subject: [PATCH 3/9] Upgrade ArduinoJson from 6.21.0 to 6.21.1 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index 33b0e980..ca4138f0 100644 --- a/platformio.ini +++ b/platformio.ini @@ -27,7 +27,7 @@ build_unflags = lib_deps = https://github.com/yubox-node-org/ESPAsyncWebServer - bblanchon/ArduinoJson @ ^6.21.0 + bblanchon/ArduinoJson @ ^6.21.1 https://github.com/bertmelis/espMqttClient.git#v1.4.2 nrf24/RF24 @ ^1.4.5 olikraus/U8g2 @ ^2.34.16 From 86733361517542fc418fc5da1cdc6e8f91649a1a Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Thu, 6 Apr 2023 22:40:01 +0200 Subject: [PATCH 4/9] Upgrade U8g2 from 2.34.16 to 2.34.17 --- platformio.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/platformio.ini b/platformio.ini index ca4138f0..ecf1632d 100644 --- a/platformio.ini +++ b/platformio.ini @@ -30,7 +30,7 @@ lib_deps = bblanchon/ArduinoJson @ ^6.21.1 https://github.com/bertmelis/espMqttClient.git#v1.4.2 nrf24/RF24 @ ^1.4.5 - olikraus/U8g2 @ ^2.34.16 + olikraus/U8g2 @ ^2.34.17 buelowp/sunset @ ^1.1.7 extra_scripts = From 12d7349699ba2c8e35883b7cbfcc98b34a92a294 Mon Sep 17 00:00:00 2001 From: Thomas Basler Date: Thu, 6 Apr 2023 22:48:06 +0200 Subject: [PATCH 5/9] webapp: add app.js.gz --- webapp_dist/js/app.js.gz | Bin 151893 -> 151943 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/webapp_dist/js/app.js.gz b/webapp_dist/js/app.js.gz index 6851479900b69aebc94a6d6a03c61f7d8e911b00..5d8b9b4d376c4df20c4b8c081e2f88a3e251e44a 100644 GIT binary patch delta 33952 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

jHM6Z- zukw#BxHV`zI0%vo|7j@@<#c6qb0_}c(0TuK0l}n3^SAuW?QUwjJ_`pH`-|z)e=n=> zKm8cG!4kHYM)id#nUG|oMFKkLHUfigmxdD)n~4_M3f-#puhml?-l9E+$QxUge=yS7 z2fyT3*_Ge^uKTPC~WdJoQJ z*kA~;LFf}jxac{7H8>;z+1?af(;DGJxVQ+@%*;&NW!ip8Fi}C~vC4q$f4#1ngczwD zz+>vl*nF7aqo!e$e*~kPgKx4H!!KThVJE;j-I%2;rN|=G@-)22sc#~hop)ra{o;Xo zr*rrU8cmc^W+t4tU+2>cwWWZdfAFJv?(?FaIBpP}m28avi~>}6U=EnfBXDwN7qBv+NsHa*?OW{w}uYN5kE2h%&Y`95}Ir<+#uR! z$Z2!{L*o*gtQ&LxO)|KJQqK5dJ3W{En)RPmmmsNw|D1P= z6%@h7ZDv2?AP#d4Mt&d-RBm+wzeSnTT$YrEIC1+iF(V;LXB1zkz3~kE?&hV z9&#%}JHGtT{Dvv>e|HVCyp+f9xWZHwF0GG*kDMw`8!2nyA1`DzUp#^V9KjUM2-tcq zlK|yz6S4hFCIZUM74cAz)+{pVQb3O%!@|9cglYeNt~g0op(p1Ck>NDCf%3qD8KuH! zzuU;}HY|2u7rlfsRoY*?EWWP) q-SDDc6?!eZSnru1&-up*rbt^``2(C6E9-yvpZ*{2_FyePcM1UIGkFLA From 00def1d8d1a74e4ecd7b49faec8c029293cd134a Mon Sep 17 00:00:00 2001 From: qubeck Date: Fri, 7 Apr 2023 20:20:00 +0200 Subject: [PATCH 6/9] Generic SML based power meters support (#146) * add support for energy & power readings on SML based power meters, taking OBIS 16.7.1 for power (using mod. SML Parser lib. by olliiiver) * switched SML read to use software serial * made total power meter response controled by meter source to obtain either the sum of phase powers or explicit total power provided by meter * made mqtt subscriptions to power meter topics meter source dependend * simplified SML read loop and OBIS handler registration, + minor refactoring * minor cleanup/style changes and optim. PowerMeter * fixed build, add SOURCE_SML == 4 * removed optional usage of HW serial for SML power meter * switched to usage of _powerMeter1Power for SML power reading to allign better with existing code --------- Co-authored-by: helgeerbe --- include/PowerMeter.h | 24 +- lib/SMLParser/sml.cpp | 405 +++++++++++++++++++++++ lib/SMLParser/sml.h | 106 ++++++ lib/SMLParser/smlCrcTable.h | 42 +++ platformio.ini | 1 + src/PowerMeter.cpp | 70 +++- webapp/src/locales/de.json | 1 + webapp/src/locales/en.json | 1 + webapp/src/views/PowerMeterAdminView.vue | 3 +- 9 files changed, 637 insertions(+), 16 deletions(-) create mode 100644 lib/SMLParser/sml.cpp create mode 100644 lib/SMLParser/sml.h create mode 100644 lib/SMLParser/smlCrcTable.h diff --git a/include/PowerMeter.h b/include/PowerMeter.h index 8b1ff335..c7a10698 100644 --- a/include/PowerMeter.h +++ b/include/PowerMeter.h @@ -7,6 +7,7 @@ #include #include #include "SDM.h" +#include "sml.h" #ifndef SDM_RX_PIN #define SDM_RX_PIN 13 @@ -16,6 +17,16 @@ #define SDM_TX_PIN 32 #endif +#ifndef SML_RX_PIN +#define SML_RX_PIN 35 +#endif + +typedef struct { + const unsigned char OBIS[6]; + void (*Fn)(double&); + float* Arg; +} OBISHandler; + class PowerMeterClass { public: enum SOURCE { @@ -23,6 +34,7 @@ public: SOURCE_SDM1PH = 1, SOURCE_SDM3PH = 2, SOURCE_HTTP = 3, + SOURCE_SML = 4 }; void init(); void mqtt(); @@ -40,14 +52,20 @@ private: float _powerMeter1Power = 0.0; float _powerMeter2Power = 0.0; float _powerMeter3Power = 0.0; - float _powerMeterTotalPower = 0.0; float _powerMeter1Voltage = 0.0; float _powerMeter2Voltage = 0.0; float _powerMeter3Voltage = 0.0; - float _PowerMeterImport = 0.0; - float _PowerMeterExport = 0.0; + float _powerMeterImport = 0.0; + float _powerMeterExport = 0.0; bool mqttInitDone = false; + + bool smlReadLoop(); + const std::list smlHandlerList{ + {{0x01, 0x00, 0x10, 0x07, 0x00, 0xff}, &smlOBISW, &_powerMeter1Power}, + {{0x01, 0x00, 0x01, 0x08, 0x00, 0xff}, &smlOBISWh, &_powerMeterImport}, + {{0x01, 0x00, 0x02, 0x08, 0x00, 0xff}, &smlOBISWh, &_powerMeterExport} + }; }; extern PowerMeterClass PowerMeter; diff --git a/lib/SMLParser/sml.cpp b/lib/SMLParser/sml.cpp new file mode 100644 index 00000000..7a378f63 --- /dev/null +++ b/lib/SMLParser/sml.cpp @@ -0,0 +1,405 @@ +#include +#include + +#include "sml.h" +#include "smlCrcTable.h" + +#ifdef SML_DEBUG +char logBuff[200]; + +#ifdef SML_NATIVE +#define SML_LOG(...) \ + do { \ + printf(__VA_ARGS__); \ + } while (0) +#define SML_TREELOG(level, ...) \ + do { \ + printf("%.*s", level, " "); \ + printf(__VA_ARGS__); \ + } while (0) +#elif ARDUINO +#include +#define SML_LOG(...) \ + do { \ + sprintf(logBuff, __VA_ARGS__); \ + Serial.print(logBuff); \ + } while (0) +#define SML_TREELOG(level, ...) \ + do { \ + sprintf(logBuff, __VA_ARGS__); \ + Serial.print(logBuff); \ + } while (0) +#endif + +#else +#define SML_LOG(...) \ + do { \ + } while (0) +#define SML_TREELOG(level, ...) \ + do { \ + } while (0) +#endif + +#define MAX_LIST_SIZE 80 +#define MAX_TREE_SIZE 10 + +static sml_states_t currentState = SML_START; +static char nodes[MAX_TREE_SIZE]; +static unsigned char currentLevel = 0; +static unsigned short crc = 0xFFFF; +static signed char sc; +static unsigned short crcMine = 0xFFFF; +static unsigned short crcReceived = 0x0000; +static unsigned char len = 4; +static unsigned char listBuffer[MAX_LIST_SIZE]; /* keeps a list + as length + state + data */ +static unsigned char listPos = 0; + +void crc16(unsigned char &byte) +{ +#ifdef ARDUINO + crc = + pgm_read_word_near(&smlCrcTable[(byte ^ crc) & 0xff]) ^ (crc >> 8 & 0xff); +#else + crc = smlCrcTable[(byte ^ crc) & 0xff] ^ (crc >> 8 & 0xff); +#endif +} + +void setState(sml_states_t state, int byteLen) +{ + currentState = state; + len = byteLen; +} + +void pushListBuffer(unsigned char byte) +{ + if (listPos < MAX_LIST_SIZE) { + listBuffer[listPos++] = byte; + } +} + +void reduceList() +{ + if (currentLevel <= MAX_TREE_SIZE && nodes[currentLevel] > 0) + nodes[currentLevel]--; +} + +void smlNewList(unsigned char size) +{ + reduceList(); + if (currentLevel < MAX_TREE_SIZE) + currentLevel++; + nodes[currentLevel] = size; + SML_TREELOG(currentLevel, "LISTSTART on level %i with %i nodes\n", + currentLevel, size); + setState(SML_LISTSTART, size); + // @todo workaround for lists inside obis lists + if (size > 5) { + listPos = 0; + memset(listBuffer, '\0', MAX_LIST_SIZE); + } + else { + pushListBuffer(size); + pushListBuffer(currentState); + } +} + +void checkMagicByte(unsigned char &byte) +{ + unsigned int size = 0; + while (currentLevel > 0 && nodes[currentLevel] == 0) { + /* go back in tree if no nodes remaining */ + SML_TREELOG(currentLevel, "back to previous list\n"); + currentLevel--; + } + if (byte > 0x70 && byte <= 0x7F) { + /* new list */ + size = byte & 0x0F; + smlNewList(size); + } + else if (byte >= 0x01 && byte <= 0x6F && nodes[currentLevel] > 0) { + if (byte == 0x01) { + /* no data, get next */ + SML_TREELOG(currentLevel, " Data %i (empty)\n", nodes[currentLevel]); + pushListBuffer(0); + pushListBuffer(currentState); + if (nodes[currentLevel] == 1) { + setState(SML_LISTEND, 1); + SML_TREELOG(currentLevel, "LISTEND\n"); + } + else { + setState(SML_NEXT, 1); + } + } + else { + size = (byte & 0x0F) - 1; + setState(SML_DATA, size); + if ((byte & 0xF0) == 0x50) { + setState(SML_DATA_SIGNED_INT, size); + } + else if ((byte & 0xF0) == 0x60) { + setState(SML_DATA_UNSIGNED_INT, size); + } + else if ((byte & 0xF0) == 0x00) { + setState(SML_DATA_OCTET_STRING, size); + } + SML_TREELOG(currentLevel, + " Data %i (length = %i%s): ", nodes[currentLevel], size, + (currentState == SML_DATA_SIGNED_INT) ? ", signed int" + : (currentState == SML_DATA_UNSIGNED_INT) ? ", unsigned int" + : (currentState == SML_DATA_OCTET_STRING) ? ", octet string" + : ""); + pushListBuffer(size); + pushListBuffer(currentState); + } + reduceList(); + } + else if (byte == 0x00) { + /* end of block */ + reduceList(); + SML_TREELOG(currentLevel, "End of block at level %i\n", currentLevel); + if (currentLevel == 0) { + setState(SML_NEXT, 1); + } + else { + setState(SML_BLOCKEND, 1); + } + } + else if (byte & 0x80) { + // MSB bit is set, another TL byte will follow + if (byte >= 0x80 && byte <= 0x8F) { + // Datatype Octet String + setState(SML_HDATA, (byte & 0x0F) << 4); + } + else if (byte >= 0xF0 /*&& byte <= 0xFF*/) { + /* Datatype List of ...*/ + setState(SML_LISTEXTENDED, (byte & 0x0F) << 4); + } + } + else if (byte == 0x1B && currentLevel == 0) { + /* end sequence */ + setState(SML_END, 3); + } + else { + /* Unexpected Byte */ + SML_TREELOG(currentLevel, + "UNEXPECTED magicbyte >%02X< at currentLevel %i\n", byte, + currentLevel); + setState(SML_UNEXPECTED, 4); + } +} + +sml_states_t smlState(unsigned char ¤tByte) +{ + unsigned char size; + if (len > 0) + len--; + crc16(currentByte); + switch (currentState) { + case SML_UNEXPECTED: + case SML_CHECKSUM_ERROR: + case SML_FINAL: + case SML_START: + currentState = SML_START; + currentLevel = 0; // Reset current level at the begin of a new transmission + // to prevent problems + if (currentByte != 0x1b) + setState(SML_UNEXPECTED, 4); + if (len == 0) { + SML_TREELOG(0, "START\n"); + /* completely clean any garbage from crc checksum */ + crc = 0xFFFF; + currentByte = 0x1b; + crc16(currentByte); + crc16(currentByte); + crc16(currentByte); + crc16(currentByte); + setState(SML_VERSION, 4); + } + break; + case SML_VERSION: + if (currentByte != 0x01) + setState(SML_UNEXPECTED, 4); + if (len == 0) { + setState(SML_BLOCKSTART, 1); + } + break; + case SML_END: + if (currentByte != 0x1b) { + SML_LOG("UNEXPECTED char >%02X< at SML_END\n", currentByte); + setState(SML_UNEXPECTED, 4); + } + if (len == 0) { + setState(SML_CHECKSUM, 4); + } + break; + case SML_CHECKSUM: + // SML_LOG("CHECK: %02X\n", currentByte); + if (len == 2) { + crcMine = crc ^ 0xFFFF; + } + if (len == 1) { + crcReceived += currentByte; + } + if (len == 0) { + crcReceived = crcReceived | (currentByte << 8); + SML_LOG("Received checksum: %02X\n", crcReceived); + SML_LOG("Calculated checksum: %02X\n", crcMine); + if (crcMine == crcReceived) { + setState(SML_FINAL, 4); + } + else { + setState(SML_CHECKSUM_ERROR, 4); + } + crc = 0xFFFF; + crcReceived = 0x000; /* reset CRC */ + } + break; + case SML_HDATA: + size = len + currentByte - 1; + setState(SML_DATA, size); + pushListBuffer(size); + pushListBuffer(currentState); + SML_TREELOG(currentLevel, " Data (length = %i): ", size); + break; + case SML_LISTEXTENDED: + size = len + (currentByte & 0x0F); + SML_TREELOG(currentLevel, "Extended List with Size=%i\n", size); + smlNewList(size); + break; + case SML_DATA: + case SML_DATA_SIGNED_INT: + case SML_DATA_UNSIGNED_INT: + case SML_DATA_OCTET_STRING: + SML_LOG("%02X ", currentByte); + pushListBuffer(currentByte); + if (nodes[currentLevel] == 0 && len == 0) { + SML_LOG("\n"); + SML_TREELOG(currentLevel, "LISTEND on level %i\n", currentLevel); + currentState = SML_LISTEND; + } + else if (len == 0) { + currentState = SML_DATAEND; + SML_LOG("\n"); + } + break; + case SML_DATAEND: + case SML_NEXT: + case SML_LISTSTART: + case SML_LISTEND: + case SML_BLOCKSTART: + case SML_BLOCKEND: + checkMagicByte(currentByte); + break; + } + return currentState; +} + +bool smlOBISCheck(const unsigned char *obis) +{ + return (memcmp(obis, &listBuffer[2], 6) == 0); +} + +void smlOBISManufacturer(unsigned char *str, int maxSize) +{ + int i = 0, pos = 0, size = 0; + while (i < listPos) { + size = (int)listBuffer[i]; + i++; + pos++; + if (pos == 6) { + /* get manufacturer at position 6 in list */ + size = (size > maxSize - 1) ? maxSize : size; + memcpy(str, &listBuffer[i + 1], size); + str[size + 1] = 0; + } + i += size + 1; + } +} + +void smlPow(double &val, signed char &scaler) +{ + if (scaler < 0) { + while (scaler++) { + val /= 10; + } + } + else { + while (scaler--) { + val *= 10; + } + } +} + +void smlOBISByUnit(long long int &val, signed char &scaler, sml_units_t unit) +{ + unsigned char i = 0, pos = 0, size = 0, y = 0, skip = 0; + sml_states_t type; + val = -1; /* unknown or error */ + while (i < listPos) { + pos++; + size = (int)listBuffer[i++]; + type = (sml_states_t)listBuffer[i++]; + if (type == SML_LISTSTART && size > 0) { + // skip a list inside an obis list + skip = size; + while (skip > 0) { + size = (int)listBuffer[i++]; + type = (sml_states_t)listBuffer[i++]; + i += size; + skip--; + } + size = 0; + } + if (pos == 4 && listBuffer[i] != unit) { + /* return unknown (-1) if unit does not match */ + return; + } + if (pos == 5) { + scaler = listBuffer[i]; + } + if (pos == 6) { + y = size; + // initialize 64bit signed integer based on MSB from received value + val = + (type == SML_DATA_SIGNED_INT && (listBuffer[i] & (1 << 7))) ? ~0 : 0; + for (y = 0; y < size; y++) { + // left shift received bytes to 64 bit signed integer + val = (val << 8) | listBuffer[i + y]; + } + } + i += size; + } +} + +void smlOBISWh(double &wh) +{ + long long int val; + smlOBISByUnit(val, sc, SML_WATT_HOUR); + wh = val; + smlPow(wh, sc); +} + +void smlOBISW(double &w) +{ + long long int val; + smlOBISByUnit(val, sc, SML_WATT); + w = val; + smlPow(w, sc); +} + +void smlOBISVolt(double &v) +{ + long long int val; + smlOBISByUnit(val, sc, SML_VOLT); + v = val; + smlPow(v, sc); +} + +void smlOBISAmpere(double &a) +{ + long long int val; + smlOBISByUnit(val, sc, SML_AMPERE); + a = val; + smlPow(a, sc); +} diff --git a/lib/SMLParser/sml.h b/lib/SMLParser/sml.h new file mode 100644 index 00000000..ac6405df --- /dev/null +++ b/lib/SMLParser/sml.h @@ -0,0 +1,106 @@ +#ifndef SML_H +#define SML_H + +#include + +typedef enum { + SML_START, + SML_END, + SML_VERSION, + SML_NEXT, + SML_LISTSTART, + SML_LISTEND, + SML_LISTEXTENDED, + SML_DATA, + SML_HDATA, + SML_DATAEND, + SML_BLOCKSTART, + SML_BLOCKEND, + SML_CHECKSUM, + SML_CHECKSUM_ERROR, /* calculated checksum does not match */ + SML_UNEXPECTED, /* unexpected byte received */ + SML_FINAL, /* final state, checksum OK */ + SML_DATA_SIGNED_INT, + SML_DATA_UNSIGNED_INT, + SML_DATA_OCTET_STRING, +} sml_states_t; + +typedef enum { + SML_YEAR = 1, + SML_MONTH = 2, + SML_WEEK = 3, + SML_DAY = 4, + SML_HOUR = 5, + SML_MIN = 6, + SML_SECOND = 7, + SML_DEGREE = 8, + SML_DEGREE_CELSIUS = 9, + SML_CURRENCY = 10, + SML_METRE = 11, + SML_METRE_PER_SECOND = 12, + SML_CUBIC_METRE = 13, + SML_CUBIC_METRE_CORRECTED = 14, + SML_CUBIC_METRE_PER_HOUR = 15, + SML_CUBIC_METRE_PER_HOUR_CORRECTED = 16, + SML_CUBIC_METRE_PER_DAY = 17, + SML_CUBIC_METRE_PER_DAY_CORRECTED = 18, + SML_LITRE = 19, + SML_KILOGRAM = 20, + SML_NEWTON = 21, + SML_NEWTONMETER = 22, + SML_PASCAL = 23, + SML_BAR = 24, + SML_JOULE = 25, + SML_JOULE_PER_HOUR = 26, + SML_WATT = 27, + SML_VOLT_AMPERE = 28, + SML_VAR = 29, + SML_WATT_HOUR = 30, + SML_VOLT_AMPERE_HOUR = 31, + SML_VAR_HOUR = 32, + SML_AMPERE = 33, + SML_COULOMB = 34, + SML_VOLT = 35, + SML_VOLT_PER_METRE = 36, + SML_FARAD = 37, + SML_OHM = 38, + SML_OHM_METRE = 39, + SML_WEBER = 40, + SML_TESLA = 41, + SML_AMPERE_PER_METRE = 42, + SML_HENRY = 43, + SML_HERTZ = 44, + SML_ACTIVE_ENERGY_METER_CONSTANT_OR_PULSE_VALUE = 45, + SML_REACTIVE_ENERGY_METER_CONSTANT_OR_PULSE_VALUE = 46, + SML_APPARENT_ENERGY_METER_CONSTANT_OR_PULSE_VALUE = 47, + SML_VOLT_SQUARED_HOURS = 48, + SML_AMPERE_SQUARED_HOURS = 49, + SML_KILOGRAM_PER_SECOND = 50, + SML_KELVIN = 52, + SML_VOLT_SQUARED_HOUR_METER_CONSTANT_OR_PULSE_VALUE = 53, + SML_AMPERE_SQUARED_HOUR_METER_CONSTANT_OR_PULSE_VALUE = 54, + SML_METER_CONSTANT_OR_PULSE_VALUE = 55, + SML_PERCENTAGE = 56, + SML_AMPERE_HOUR = 57, + SML_ENERGY_PER_VOLUME = 60, + SML_CALORIFIC_VALUE = 61, + SML_MOLE_PERCENT = 62, + SML_MASS_DENSITY = 63, + SML_PASCAL_SECOND = 64, + SML_RESERVED = 253, + SML_OTHER_UNIT = 254, + SML_COUNT = 255 +} sml_units_t; + +sml_states_t smlState(unsigned char &byte); +bool smlOBISCheck(const unsigned char *obis); +void smlOBISManufacturer(unsigned char *str, int maxSize); +void smlOBISByUnit(long long int &wh, signed char &scaler, sml_units_t unit); + +// Be aware that double on Arduino UNO is just 32 bit +void smlOBISWh(double &wh); +void smlOBISW(double &w); +void smlOBISVolt(double &v); +void smlOBISAmpere(double &a); + +#endif diff --git a/lib/SMLParser/smlCrcTable.h b/lib/SMLParser/smlCrcTable.h new file mode 100644 index 00000000..8f6c1c19 --- /dev/null +++ b/lib/SMLParser/smlCrcTable.h @@ -0,0 +1,42 @@ +#ifndef SML_CRC_TABLE_H +#define SML_CRC_TABLE_H + +#include + +#ifdef ARDUINO +#include +static const uint16_t smlCrcTable[256] PROGMEM = +#else +static const uint16_t smlCrcTable[256] = +#endif + {0x0000, 0x1189, 0x2312, 0x329B, 0x4624, 0x57AD, 0x6536, 0x74BF, 0x8C48, + 0x9DC1, 0xAF5A, 0xBED3, 0xCA6C, 0xDBE5, 0xE97E, 0xF8F7, 0x1081, 0x0108, + 0x3393, 0x221A, 0x56A5, 0x472C, 0x75B7, 0x643E, 0x9CC9, 0x8D40, 0xBFDB, + 0xAE52, 0xDAED, 0xCB64, 0xF9FF, 0xE876, 0x2102, 0x308B, 0x0210, 0x1399, + 0x6726, 0x76AF, 0x4434, 0x55BD, 0xAD4A, 0xBCC3, 0x8E58, 0x9FD1, 0xEB6E, + 0xFAE7, 0xC87C, 0xD9F5, 0x3183, 0x200A, 0x1291, 0x0318, 0x77A7, 0x662E, + 0x54B5, 0x453C, 0xBDCB, 0xAC42, 0x9ED9, 0x8F50, 0xFBEF, 0xEA66, 0xD8FD, + 0xC974, 0x4204, 0x538D, 0x6116, 0x709F, 0x0420, 0x15A9, 0x2732, 0x36BB, + 0xCE4C, 0xDFC5, 0xED5E, 0xFCD7, 0x8868, 0x99E1, 0xAB7A, 0xBAF3, 0x5285, + 0x430C, 0x7197, 0x601E, 0x14A1, 0x0528, 0x37B3, 0x263A, 0xDECD, 0xCF44, + 0xFDDF, 0xEC56, 0x98E9, 0x8960, 0xBBFB, 0xAA72, 0x6306, 0x728F, 0x4014, + 0x519D, 0x2522, 0x34AB, 0x0630, 0x17B9, 0xEF4E, 0xFEC7, 0xCC5C, 0xDDD5, + 0xA96A, 0xB8E3, 0x8A78, 0x9BF1, 0x7387, 0x620E, 0x5095, 0x411C, 0x35A3, + 0x242A, 0x16B1, 0x0738, 0xFFCF, 0xEE46, 0xDCDD, 0xCD54, 0xB9EB, 0xA862, + 0x9AF9, 0x8B70, 0x8408, 0x9581, 0xA71A, 0xB693, 0xC22C, 0xD3A5, 0xE13E, + 0xF0B7, 0x0840, 0x19C9, 0x2B52, 0x3ADB, 0x4E64, 0x5FED, 0x6D76, 0x7CFF, + 0x9489, 0x8500, 0xB79B, 0xA612, 0xD2AD, 0xC324, 0xF1BF, 0xE036, 0x18C1, + 0x0948, 0x3BD3, 0x2A5A, 0x5EE5, 0x4F6C, 0x7DF7, 0x6C7E, 0xA50A, 0xB483, + 0x8618, 0x9791, 0xE32E, 0xF2A7, 0xC03C, 0xD1B5, 0x2942, 0x38CB, 0x0A50, + 0x1BD9, 0x6F66, 0x7EEF, 0x4C74, 0x5DFD, 0xB58B, 0xA402, 0x9699, 0x8710, + 0xF3AF, 0xE226, 0xD0BD, 0xC134, 0x39C3, 0x284A, 0x1AD1, 0x0B58, 0x7FE7, + 0x6E6E, 0x5CF5, 0x4D7C, 0xC60C, 0xD785, 0xE51E, 0xF497, 0x8028, 0x91A1, + 0xA33A, 0xB2B3, 0x4A44, 0x5BCD, 0x6956, 0x78DF, 0x0C60, 0x1DE9, 0x2F72, + 0x3EFB, 0xD68D, 0xC704, 0xF59F, 0xE416, 0x90A9, 0x8120, 0xB3BB, 0xA232, + 0x5AC5, 0x4B4C, 0x79D7, 0x685E, 0x1CE1, 0x0D68, 0x3FF3, 0x2E7A, 0xE70E, + 0xF687, 0xC41C, 0xD595, 0xA12A, 0xB0A3, 0x8238, 0x93B1, 0x6B46, 0x7ACF, + 0x4854, 0x59DD, 0x2D62, 0x3CEB, 0x0E70, 0x1FF9, 0xF78F, 0xE606, 0xD49D, + 0xC514, 0xB1AB, 0xA022, 0x92B9, 0x8330, 0x7BC7, 0x6A4E, 0x58D5, 0x495C, + 0x3DE3, 0x2C6A, 0x1EF1, 0x0F78}; + +#endif \ No newline at end of file diff --git a/platformio.ini b/platformio.ini index c5cbca08..328ce696 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 + plerup/EspSoftwareSerial@^8.0.1 mobizt/FirebaseJson @ ^3.0.6 extra_scripts = diff --git a/src/PowerMeter.cpp b/src/PowerMeter.cpp index da2fc2d3..ffe29101 100644 --- a/src/PowerMeter.cpp +++ b/src/PowerMeter.cpp @@ -10,11 +10,14 @@ #include "SDM.h" #include "MessageOutput.h" #include +#include PowerMeterClass PowerMeter; SDM sdm(Serial2, 9600, NOT_A_PIN, SERIAL_8N1, SDM_RX_PIN, SDM_TX_PIN); +SoftwareSerial inputSerial; + void PowerMeterClass::init() { using std::placeholders::_1; @@ -28,8 +31,12 @@ void PowerMeterClass::init() _lastPowerMeterUpdate = 0; CONFIG_T& config = Configuration.get(); + + if (!config.PowerMeter_Enabled) { + return; + } - if (config.PowerMeter_Enabled && config.PowerMeter_Source == 0) { + if (config.PowerMeter_Source == SOURCE_MQTT) { if (strlen(config.PowerMeter_MqttTopicPowerMeter1) > 0) { MqttSettings.subscribe(config.PowerMeter_MqttTopicPowerMeter1, 0, std::bind(&PowerMeterClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6)); } @@ -43,16 +50,30 @@ void PowerMeterClass::init() } } - mqttInitDone = true; + if(config.PowerMeter_Source == SOURCE_SDM1PH || config.PowerMeter_Source == SOURCE_SDM3PH) { + sdm.begin(); + } + + if (config.PowerMeter_Source == SOURCE_HTTP) { + HttpPowerMeter.init(); + } - sdm.begin(); - HttpPowerMeter.init(); + if(config.PowerMeter_Source == SOURCE_SML) { + pinMode(SML_RX_PIN, INPUT); + inputSerial.begin(9600, SWSERIAL_8N1, SML_RX_PIN, -1, false, 128, 95); + inputSerial.enableRx(true); + inputSerial.enableTx(false); + inputSerial.flush(); + } + + mqttInitDone = true; } 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 != SOURCE_MQTT) { + + if (!config.PowerMeter_Enabled || config.PowerMeter_Source != SOURCE_MQTT) { return; } @@ -96,8 +117,8 @@ void PowerMeterClass::mqtt() MqttSettings.publish(topic + "/voltage1", String(_powerMeter1Voltage)); MqttSettings.publish(topic + "/voltage2", String(_powerMeter2Voltage)); MqttSettings.publish(topic + "/voltage3", String(_powerMeter3Voltage)); - MqttSettings.publish(topic + "/import", String(_PowerMeterImport)); - MqttSettings.publish(topic + "/export", String(_PowerMeterExport)); + MqttSettings.publish(topic + "/import", String(_powerMeterImport)); + MqttSettings.publish(topic + "/export", String(_powerMeterExport)); } } @@ -105,6 +126,12 @@ void PowerMeterClass::loop() { CONFIG_T& config = Configuration.get(); + if (config.PowerMeter_Enabled && config.PowerMeter_Source == SOURCE_SML) { + if (!smlReadLoop()) { + return; + } + } + if (!config.PowerMeter_Enabled || (millis() - _lastPowerMeterCheck) < (config.PowerMeter_Interval * 1000)) { return; @@ -112,15 +139,15 @@ void PowerMeterClass::loop() uint8_t _address = config.PowerMeter_SdmAddress; - if (config.PowerMeter_Source== SOURCE_SDM1PH) { + 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)); + _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) { @@ -130,8 +157,8 @@ void PowerMeterClass::loop() _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)); + _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) { @@ -149,3 +176,22 @@ void PowerMeterClass::loop() _lastPowerMeterCheck = millis(); } + +bool PowerMeterClass::smlReadLoop() +{ + while (inputSerial.available()) { + double readVal = 0; + unsigned char smlCurrentChar = inputSerial.read(); + sml_states_t smlCurrentState = smlState(smlCurrentChar); + if (smlCurrentState == SML_LISTEND) { + for(auto& handler: smlHandlerList) { + if (smlOBISCheck(handler.OBIS)) { + handler.Fn(readVal); + *handler.Arg = readVal; + } + } + } else if (smlCurrentState == SML_FINAL) + return true; + } + return false; +} diff --git a/webapp/src/locales/de.json b/webapp/src/locales/de.json index da25cf6b..1fd5eb6d 100644 --- a/webapp/src/locales/de.json +++ b/webapp/src/locales/de.json @@ -470,6 +470,7 @@ "typeSDM1ph": "SDM 1 phase (SDM120/220/230)", "typeSDM3ph": "SDM 3 phase (SDM72/630)", "typeHTTP": "HTTP(S) + JSON", + "typeSML": "SML (OBIS 16.7.0)", "MqttTopicPowerMeter1": "MQTT topic - Stromzähler #1", "MqttTopicPowerMeter2": "MQTT topic - Stromzähler #2 (Optional)", "MqttTopicPowerMeter3": "MQTT topic - Stromzähler #3 (Optional)", diff --git a/webapp/src/locales/en.json b/webapp/src/locales/en.json index 537db46a..45498cef 100644 --- a/webapp/src/locales/en.json +++ b/webapp/src/locales/en.json @@ -470,6 +470,7 @@ "typeSDM1ph": "SDM 1 phase (SDM120/220/230)", "typeSDM3ph": "SDM 3 phase (SDM72/630)", "typeHTTP": "HTTP(s) + JSON", + "typeSML": "SML (OBIS 16.7.0)", "MqttTopicPowerMeter1": "MQTT topic - Power meter #1", "MqttTopicPowerMeter2": "MQTT topic - Power meter #2", "MqttTopicPowerMeter3": "MQTT topic - Power meter #3", diff --git a/webapp/src/views/PowerMeterAdminView.vue b/webapp/src/views/PowerMeterAdminView.vue index c227afd8..fc45009d 100644 --- a/webapp/src/views/PowerMeterAdminView.vue +++ b/webapp/src/views/PowerMeterAdminView.vue @@ -215,10 +215,11 @@ export default defineComponent({ 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') }, + { key: 3, value: this.$t('powermeteradmin.typeHTTP') }, + { key: 4, value: this.$t('powermeteradmin.typeSML') }, ], alertMessage: "", alertType: "info", From 19b2dd4c7a181b933f6432bd7929dd0c5b739a09 Mon Sep 17 00:00:00 2001 From: berni2288 Date: Fri, 7 Apr 2023 20:22:35 +0200 Subject: [PATCH 7/9] PowerMeter: Whitespace and {} fixes --- src/PowerMeter.cpp | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/PowerMeter.cpp b/src/PowerMeter.cpp index ffe29101..0c5e55b8 100644 --- a/src/PowerMeter.cpp +++ b/src/PowerMeter.cpp @@ -53,19 +53,19 @@ void PowerMeterClass::init() if(config.PowerMeter_Source == SOURCE_SDM1PH || config.PowerMeter_Source == SOURCE_SDM3PH) { sdm.begin(); } - + if (config.PowerMeter_Source == SOURCE_HTTP) { HttpPowerMeter.init(); } - if(config.PowerMeter_Source == SOURCE_SML) { + if (config.PowerMeter_Source == SOURCE_SML) { pinMode(SML_RX_PIN, INPUT); inputSerial.begin(9600, SWSERIAL_8N1, SML_RX_PIN, -1, false, 128, 95); inputSerial.enableRx(true); inputSerial.enableTx(false); inputSerial.flush(); } - + mqttInitDone = true; } @@ -184,14 +184,16 @@ bool PowerMeterClass::smlReadLoop() unsigned char smlCurrentChar = inputSerial.read(); sml_states_t smlCurrentState = smlState(smlCurrentChar); if (smlCurrentState == SML_LISTEND) { - for(auto& handler: smlHandlerList) { + for (auto& handler: smlHandlerList) { if (smlOBISCheck(handler.OBIS)) { handler.Fn(readVal); *handler.Arg = readVal; } } - } else if (smlCurrentState == SML_FINAL) + } else if (smlCurrentState == SML_FINAL) { return true; + } } + return false; } From 4bff31e3b1cbc8afda162ab5bbb7e7bd0396bae3 Mon Sep 17 00:00:00 2001 From: MalteSchm Date: Wed, 12 Apr 2023 06:45:41 +0200 Subject: [PATCH 8/9] adding option to disable power limiter via mqtt --- README.md | 6 + include/PowerLimiter.h | 3 + src/Configuration.cpp | 850 ++++++++++++++++----------------- src/MqttHandlePowerLimiter.cpp | 83 ++++ src/PowerLimiter.cpp | 12 +- src/WebApi_powerlimiter.cpp | 290 +++++------ 6 files changed, 673 insertions(+), 571 deletions(-) create mode 100644 src/MqttHandlePowerLimiter.cpp diff --git a/README.md b/README.md index 887e5ab3..eae35335 100644 --- a/README.md +++ b/README.md @@ -188,6 +188,12 @@ Topics for 3 phases of a power meter is configurable. Given is an example for th | huawei/output_temp | R | Output air temperature | °C | | huawei/efficiency | R | Efficiency | Percentage | +## Power Limiter topics +| Topic | R / W | Description | Value / Unit | +| --------------------------------------- | ----- | ---------------------------------------------------- | -------------------------- | +| powerlimiter/cmd/disable | W | Power Limiter disable override for external PL control | 0 / 1 | +| powerlimiter/status/disabled | R | Power Limiter disable override status | 0 / 1 | + ## Currently supported Inverters * Hoymiles HM-300 * Hoymiles HM-350 diff --git a/include/PowerLimiter.h b/include/PowerLimiter.h index 96baaf7c..6e9357a1 100644 --- a/include/PowerLimiter.h +++ b/include/PowerLimiter.h @@ -26,6 +26,8 @@ public: void loop(); plStates getPowerLimiterState(); int32_t getLastRequestedPowewrLimit(); + void setDisable(bool disable); + bool getDisable(); private: uint32_t _lastCommandSent = 0; @@ -33,6 +35,7 @@ private: int32_t _lastRequestedPowerLimit = 0; uint32_t _lastLimitSetTime = 0; plStates _plState = STATE_DISCOVER; + bool _disabled = false; float _powerMeter1Power; float _powerMeter2Power; diff --git a/src/Configuration.cpp b/src/Configuration.cpp index 7b6be171..dca9921a 100644 --- a/src/Configuration.cpp +++ b/src/Configuration.cpp @@ -1,425 +1,425 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -/* - * Copyright (C) 2022 Thomas Basler and others - */ -#include "Configuration.h" -#include "MessageOutput.h" -#include "defaults.h" -#include -#include - -CONFIG_T config; - -void ConfigurationClass::init() -{ - memset(&config, 0x0, sizeof(config)); -} - -bool ConfigurationClass::write() -{ - File f = LittleFS.open(CONFIG_FILENAME, "w"); - if (!f) { - return false; - } - config.Cfg_SaveCount++; - - DynamicJsonDocument doc(JSON_BUFFER_SIZE); - - JsonObject cfg = doc.createNestedObject("cfg"); - cfg["version"] = config.Cfg_Version; - cfg["save_count"] = config.Cfg_SaveCount; - - JsonObject wifi = doc.createNestedObject("wifi"); - wifi["ssid"] = config.WiFi_Ssid; - wifi["password"] = config.WiFi_Password; - wifi["ip"] = IPAddress(config.WiFi_Ip).toString(); - wifi["netmask"] = IPAddress(config.WiFi_Netmask).toString(); - wifi["gateway"] = IPAddress(config.WiFi_Gateway).toString(); - wifi["dns1"] = IPAddress(config.WiFi_Dns1).toString(); - wifi["dns2"] = IPAddress(config.WiFi_Dns2).toString(); - wifi["dhcp"] = config.WiFi_Dhcp; - wifi["hostname"] = config.WiFi_Hostname; - - JsonObject ntp = doc.createNestedObject("ntp"); - ntp["server"] = config.Ntp_Server; - ntp["timezone"] = config.Ntp_Timezone; - ntp["timezone_descr"] = config.Ntp_TimezoneDescr; - ntp["latitude"] = config.Ntp_Latitude; - ntp["longitude"] = config.Ntp_Longitude; - - JsonObject mqtt = doc.createNestedObject("mqtt"); - mqtt["enabled"] = config.Mqtt_Enabled; - mqtt["hostname"] = config.Mqtt_Hostname; - mqtt["port"] = config.Mqtt_Port; - mqtt["username"] = config.Mqtt_Username; - mqtt["password"] = config.Mqtt_Password; - mqtt["topic"] = config.Mqtt_Topic; - mqtt["retain"] = config.Mqtt_Retain; - mqtt["publish_interval"] = config.Mqtt_PublishInterval; - - JsonObject mqtt_lwt = mqtt.createNestedObject("lwt"); - mqtt_lwt["topic"] = config.Mqtt_LwtTopic; - mqtt_lwt["value_online"] = config.Mqtt_LwtValue_Online; - mqtt_lwt["value_offline"] = config.Mqtt_LwtValue_Offline; - - JsonObject mqtt_tls = mqtt.createNestedObject("tls"); - mqtt_tls["enabled"] = config.Mqtt_Tls; - mqtt_tls["root_ca_cert"] = config.Mqtt_RootCaCert; - - JsonObject mqtt_hass = mqtt.createNestedObject("hass"); - mqtt_hass["enabled"] = config.Mqtt_Hass_Enabled; - mqtt_hass["retain"] = config.Mqtt_Hass_Retain; - mqtt_hass["topic"] = config.Mqtt_Hass_Topic; - mqtt_hass["individual_panels"] = config.Mqtt_Hass_IndividualPanels; - mqtt_hass["expire"] = config.Mqtt_Hass_Expire; - - JsonObject dtu = doc.createNestedObject("dtu"); - dtu["serial"] = config.Dtu_Serial; - dtu["poll_interval"] = config.Dtu_PollInterval; - dtu["pa_level"] = config.Dtu_PaLevel; - - JsonObject security = doc.createNestedObject("security"); - security["password"] = config.Security_Password; - security["allow_readonly"] = config.Security_AllowReadonly; - - JsonObject device = doc.createNestedObject("device"); - device["pinmapping"] = config.Dev_PinMapping; - - JsonObject display = device.createNestedObject("display"); - display["powersafe"] = config.Display_PowerSafe; - display["screensaver"] = config.Display_ScreenSaver; - display["rotation"] = config.Display_Rotation; - display["contrast"] = config.Display_Contrast; - - JsonArray inverters = doc.createNestedArray("inverters"); - for (uint8_t i = 0; i < INV_MAX_COUNT; i++) { - JsonObject inv = inverters.createNestedObject(); - inv["serial"] = config.Inverter[i].Serial; - inv["name"] = config.Inverter[i].Name; - inv["poll_enable"] = config.Inverter[i].Poll_Enable; - inv["poll_enable_night"] = config.Inverter[i].Poll_Enable_Night; - inv["command_enable"] = config.Inverter[i].Command_Enable; - inv["command_enable_night"] = config.Inverter[i].Command_Enable_Night; - - JsonArray channel = inv.createNestedArray("channel"); - for (uint8_t c = 0; c < INV_MAX_CHAN_COUNT; c++) { - JsonObject chanData = channel.createNestedObject(); - chanData["name"] = config.Inverter[i].channel[c].Name; - chanData["max_power"] = config.Inverter[i].channel[c].MaxChannelPower; - chanData["yield_total_offset"] = config.Inverter[i].channel[c].YieldTotalOffset; - } - } - - JsonObject vedirect = doc.createNestedObject("vedirect"); - vedirect["enabled"] = config.Vedirect_Enabled; - vedirect["updates_only"] = config.Vedirect_UpdatesOnly; - vedirect["poll_interval"] = config.Vedirect_PollInterval; - - JsonObject powermeter = doc.createNestedObject("powermeter"); - powermeter["enabled"] = config.PowerMeter_Enabled; - powermeter["interval"] = config.PowerMeter_Interval; - powermeter["source"] = config.PowerMeter_Source; - powermeter["mqtt_topic_powermeter_1"] = config.PowerMeter_MqttTopicPowerMeter1; - powermeter["mqtt_topic_powermeter_2"] = config.PowerMeter_MqttTopicPowerMeter2; - 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; - powerlimiter["solar_passtrough_enabled"] = config.PowerLimiter_SolarPassTroughEnabled; - powerlimiter["battery_drain_strategy"] = config.PowerLimiter_BatteryDrainStategy; - powerlimiter["interval"] = config.PowerLimiter_Interval; - powerlimiter["is_inverter_behind_powermeter"] = config.PowerLimiter_IsInverterBehindPowerMeter; - powerlimiter["inverter_id"] = config.PowerLimiter_InverterId; - powerlimiter["inverter_channel_id"] = config.PowerLimiter_InverterChannelId; - powerlimiter["target_power_consumption"] = config.PowerLimiter_TargetPowerConsumption; - powerlimiter["target_power_consumption_hysteresis"] = config.PowerLimiter_TargetPowerConsumptionHysteresis; - powerlimiter["lower_power_limit"] = config.PowerLimiter_LowerPowerLimit; - powerlimiter["upper_power_limit"] = config.PowerLimiter_UpperPowerLimit; - powerlimiter["battery_soc_start_threshold"] = config.PowerLimiter_BatterySocStartThreshold; - powerlimiter["battery_soc_stop_threshold"] = config.PowerLimiter_BatterySocStopThreshold; - powerlimiter["voltage_start_threshold"] = config.PowerLimiter_VoltageStartThreshold; - powerlimiter["voltage_stop_threshold"] = config.PowerLimiter_VoltageStopThreshold; - powerlimiter["voltage_load_correction_factor"] = config.PowerLimiter_VoltageLoadCorrectionFactor; - - JsonObject battery = doc.createNestedObject("battery"); - battery["enabled"] = config.Battery_Enabled; - - JsonObject huawei = doc.createNestedObject("huawei"); - huawei["enabled"] = config.Huawei_Enabled; - - // Serialize JSON to file - if (serializeJson(doc, f) == 0) { - MessageOutput.println("Failed to write file"); - return false; - } - - f.close(); - return true; -} - -bool ConfigurationClass::read() -{ - File f = LittleFS.open(CONFIG_FILENAME, "r", false); - - DynamicJsonDocument doc(JSON_BUFFER_SIZE); - // Deserialize the JSON document - DeserializationError error = deserializeJson(doc, f); - if (error) { - MessageOutput.println("Failed to read file, using default configuration"); - } - - JsonObject cfg = doc["cfg"]; - config.Cfg_Version = cfg["version"] | CONFIG_VERSION; - config.Cfg_SaveCount = cfg["save_count"] | 0; - - JsonObject wifi = doc["wifi"]; - strlcpy(config.WiFi_Ssid, wifi["ssid"] | WIFI_SSID, sizeof(config.WiFi_Ssid)); - strlcpy(config.WiFi_Password, wifi["password"] | WIFI_PASSWORD, sizeof(config.WiFi_Password)); - strlcpy(config.WiFi_Hostname, wifi["hostname"] | APP_HOSTNAME, sizeof(config.WiFi_Hostname)); - - IPAddress wifi_ip; - wifi_ip.fromString(wifi["ip"] | ""); - config.WiFi_Ip[0] = wifi_ip[0]; - config.WiFi_Ip[1] = wifi_ip[1]; - config.WiFi_Ip[2] = wifi_ip[2]; - config.WiFi_Ip[3] = wifi_ip[3]; - - IPAddress wifi_netmask; - wifi_netmask.fromString(wifi["netmask"] | ""); - config.WiFi_Netmask[0] = wifi_netmask[0]; - config.WiFi_Netmask[1] = wifi_netmask[1]; - config.WiFi_Netmask[2] = wifi_netmask[2]; - config.WiFi_Netmask[3] = wifi_netmask[3]; - - IPAddress wifi_gateway; - wifi_gateway.fromString(wifi["gateway"] | ""); - config.WiFi_Gateway[0] = wifi_gateway[0]; - config.WiFi_Gateway[1] = wifi_gateway[1]; - config.WiFi_Gateway[2] = wifi_gateway[2]; - config.WiFi_Gateway[3] = wifi_gateway[3]; - - IPAddress wifi_dns1; - wifi_dns1.fromString(wifi["dns1"] | ""); - config.WiFi_Dns1[0] = wifi_dns1[0]; - config.WiFi_Dns1[1] = wifi_dns1[1]; - config.WiFi_Dns1[2] = wifi_dns1[2]; - config.WiFi_Dns1[3] = wifi_dns1[3]; - - IPAddress wifi_dns2; - wifi_dns2.fromString(wifi["dns2"] | ""); - config.WiFi_Dns2[0] = wifi_dns2[0]; - config.WiFi_Dns2[1] = wifi_dns2[1]; - config.WiFi_Dns2[2] = wifi_dns2[2]; - config.WiFi_Dns2[3] = wifi_dns2[3]; - - config.WiFi_Dhcp = wifi["dhcp"] | WIFI_DHCP; - - JsonObject ntp = doc["ntp"]; - strlcpy(config.Ntp_Server, ntp["server"] | NTP_SERVER, sizeof(config.Ntp_Server)); - strlcpy(config.Ntp_Timezone, ntp["timezone"] | NTP_TIMEZONE, sizeof(config.Ntp_Timezone)); - strlcpy(config.Ntp_TimezoneDescr, ntp["timezone_descr"] | NTP_TIMEZONEDESCR, sizeof(config.Ntp_TimezoneDescr)); - config.Ntp_Latitude = ntp["latitude"] | NTP_LATITUDE; - config.Ntp_Longitude = ntp["longitude"] | NTP_LONGITUDE; - - JsonObject mqtt = doc["mqtt"]; - config.Mqtt_Enabled = mqtt["enabled"] | MQTT_ENABLED; - strlcpy(config.Mqtt_Hostname, mqtt["hostname"] | MQTT_HOST, sizeof(config.Mqtt_Hostname)); - config.Mqtt_Port = mqtt["port"] | MQTT_PORT; - strlcpy(config.Mqtt_Username, mqtt["username"] | MQTT_USER, sizeof(config.Mqtt_Username)); - strlcpy(config.Mqtt_Password, mqtt["password"] | MQTT_PASSWORD, sizeof(config.Mqtt_Password)); - strlcpy(config.Mqtt_Topic, mqtt["topic"] | MQTT_TOPIC, sizeof(config.Mqtt_Topic)); - config.Mqtt_Retain = mqtt["retain"] | MQTT_RETAIN; - config.Mqtt_PublishInterval = mqtt["publish_interval"] | MQTT_PUBLISH_INTERVAL; - - JsonObject mqtt_lwt = mqtt["lwt"]; - strlcpy(config.Mqtt_LwtTopic, mqtt_lwt["topic"] | MQTT_LWT_TOPIC, sizeof(config.Mqtt_LwtTopic)); - strlcpy(config.Mqtt_LwtValue_Online, mqtt_lwt["value_online"] | MQTT_LWT_ONLINE, sizeof(config.Mqtt_LwtValue_Online)); - strlcpy(config.Mqtt_LwtValue_Offline, mqtt_lwt["value_offline"] | MQTT_LWT_OFFLINE, sizeof(config.Mqtt_LwtValue_Offline)); - - JsonObject mqtt_tls = mqtt["tls"]; - config.Mqtt_Tls = mqtt_tls["enabled"] | MQTT_TLS; - strlcpy(config.Mqtt_RootCaCert, mqtt_tls["root_ca_cert"] | MQTT_ROOT_CA_CERT, sizeof(config.Mqtt_RootCaCert)); - - JsonObject mqtt_hass = mqtt["hass"]; - config.Mqtt_Hass_Enabled = mqtt_hass["enabled"] | MQTT_HASS_ENABLED; - config.Mqtt_Hass_Retain = mqtt_hass["retain"] | MQTT_HASS_RETAIN; - config.Mqtt_Hass_Expire = mqtt_hass["expire"] | MQTT_HASS_EXPIRE; - config.Mqtt_Hass_IndividualPanels = mqtt_hass["individual_panels"] | MQTT_HASS_INDIVIDUALPANELS; - strlcpy(config.Mqtt_Hass_Topic, mqtt_hass["topic"] | MQTT_HASS_TOPIC, sizeof(config.Mqtt_Hass_Topic)); - - JsonObject dtu = doc["dtu"]; - config.Dtu_Serial = dtu["serial"] | DTU_SERIAL; - config.Dtu_PollInterval = dtu["poll_interval"] | DTU_POLL_INTERVAL; - config.Dtu_PaLevel = dtu["pa_level"] | DTU_PA_LEVEL; - - JsonObject security = doc["security"]; - strlcpy(config.Security_Password, security["password"] | ACCESS_POINT_PASSWORD, sizeof(config.Security_Password)); - config.Security_AllowReadonly = security["allow_readonly"] | SECURITY_ALLOW_READONLY; - - JsonObject device = doc["device"]; - strlcpy(config.Dev_PinMapping, device["pinmapping"] | DEV_PINMAPPING, sizeof(config.Dev_PinMapping)); - - JsonObject display = device["display"]; - config.Display_PowerSafe = display["powersafe"] | DISPLAY_POWERSAFE; - config.Display_ScreenSaver = display["screensaver"] | DISPLAY_SCREENSAVER; - config.Display_Rotation = display["rotation"] | DISPLAY_ROTATION; - config.Display_Contrast = display["contrast"] | DISPLAY_CONTRAST; - - JsonArray inverters = doc["inverters"]; - for (uint8_t i = 0; i < INV_MAX_COUNT; i++) { - JsonObject inv = inverters[i].as(); - config.Inverter[i].Serial = inv["serial"] | 0ULL; - strlcpy(config.Inverter[i].Name, inv["name"] | "", sizeof(config.Inverter[i].Name)); - - config.Inverter[i].Poll_Enable = inv["poll_enable"] | true; - config.Inverter[i].Poll_Enable_Night = inv["poll_enable_night"] | true; - config.Inverter[i].Command_Enable = inv["command_enable"] | true; - config.Inverter[i].Command_Enable_Night = inv["command_enable_night"] | true; - - JsonArray channel = inv["channel"]; - for (uint8_t c = 0; c < INV_MAX_CHAN_COUNT; c++) { - config.Inverter[i].channel[c].MaxChannelPower = channel[c]["max_power"] | 0; - config.Inverter[i].channel[c].YieldTotalOffset = channel[c]["yield_total_offset"] | 0.0f; - strlcpy(config.Inverter[i].channel[c].Name, channel[c]["name"] | "", sizeof(config.Inverter[i].channel[c].Name)); - } - } - - JsonObject vedirect = doc["vedirect"]; - config.Vedirect_Enabled = vedirect["enabled"] | VEDIRECT_ENABLED; - config.Vedirect_UpdatesOnly = vedirect["updates_only"] | VEDIRECT_UPDATESONLY; - config.Vedirect_PollInterval = vedirect["poll_interval"] | VEDIRECT_POLL_INTERVAL; - - JsonObject powermeter = doc["powermeter"]; - config.PowerMeter_Enabled = powermeter["enabled"] | POWERMETER_ENABLED; - config.PowerMeter_Interval = powermeter["interval"] | POWERMETER_INTERVAL; - config.PowerMeter_Source = powermeter["source"] | POWERMETER_SOURCE; - strlcpy(config.PowerMeter_MqttTopicPowerMeter1, powermeter["mqtt_topic_powermeter_1"] | "", sizeof(config.PowerMeter_MqttTopicPowerMeter1)); - strlcpy(config.PowerMeter_MqttTopicPowerMeter2, powermeter["mqtt_topic_powermeter_2"] | "", sizeof(config.PowerMeter_MqttTopicPowerMeter2)); - 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; - config.PowerLimiter_SolarPassTroughEnabled = powerlimiter["solar_passtrough_enabled"] | POWERLIMITER_SOLAR_PASSTROUGH_ENABLED; - config.PowerLimiter_BatteryDrainStategy = powerlimiter["battery_drain_strategy"] | POWERLIMITER_BATTERY_DRAIN_STRATEGY; - config.PowerLimiter_Interval = POWERLIMITER_INTERVAL; - config.PowerLimiter_IsInverterBehindPowerMeter = powerlimiter["is_inverter_behind_powermeter"] | POWERLIMITER_IS_INVERTER_BEHIND_POWER_METER; - config.PowerLimiter_InverterId = powerlimiter["inverter_id"] | POWERLIMITER_INVERTER_ID; - config.PowerLimiter_InverterChannelId = powerlimiter["inverter_channel_id"] | POWERLIMITER_INVERTER_CHANNEL_ID; - config.PowerLimiter_TargetPowerConsumption = powerlimiter["target_power_consumption"] | POWERLIMITER_TARGET_POWER_CONSUMPTION; - config.PowerLimiter_TargetPowerConsumptionHysteresis = powerlimiter["target_power_consumption_hysteresis"] | POWERLIMITER_TARGET_POWER_CONSUMPTION_HYSTERESIS; - config.PowerLimiter_LowerPowerLimit = powerlimiter["lower_power_limit"] | POWERLIMITER_LOWER_POWER_LIMIT; - config.PowerLimiter_UpperPowerLimit = powerlimiter["upper_power_limit"] | POWERLIMITER_UPPER_POWER_LIMIT; - config.PowerLimiter_BatterySocStartThreshold = powerlimiter["battery_soc_start_threshold"] | POWERLIMITER_BATTERY_SOC_START_THRESHOLD; - config.PowerLimiter_BatterySocStopThreshold = powerlimiter["battery_soc_stop_threshold"] | POWERLIMITER_BATTERY_SOC_STOP_THRESHOLD; - config.PowerLimiter_VoltageStartThreshold = powerlimiter["voltage_start_threshold"] | POWERLIMITER_VOLTAGE_START_THRESHOLD; - config.PowerLimiter_VoltageStopThreshold = powerlimiter["voltage_stop_threshold"] | POWERLIMITER_VOLTAGE_STOP_THRESHOLD; - config.PowerLimiter_VoltageLoadCorrectionFactor = powerlimiter["voltage_load_correction_factor"] | POWERLIMITER_VOLTAGE_LOAD_CORRECTION_FACTOR; - - JsonObject battery = doc["battery"]; - config.Battery_Enabled = battery["enabled"] | BATTERY_ENABLED; - - JsonObject huawei = doc["huawei"]; - config.Huawei_Enabled = huawei["enabled"] | HUAWEI_ENABLED; - - f.close(); - return true; -} - -void ConfigurationClass::migrate() -{ - File f = LittleFS.open(CONFIG_FILENAME, "r", false); - if (!f) { - MessageOutput.println("Failed to open file, cancel migration"); - return; - } - - DynamicJsonDocument doc(JSON_BUFFER_SIZE); - // Deserialize the JSON document - DeserializationError error = deserializeJson(doc, f); - if (error) { - MessageOutput.printf("Failed to read file, cancel migration: %s\r\n", error.c_str()); - return; - } - - if (config.Cfg_Version < 0x00011700) { - JsonArray inverters = doc["inverters"]; - for (uint8_t i = 0; i < INV_MAX_COUNT; i++) { - JsonObject inv = inverters[i].as(); - JsonArray channels = inv["channels"]; - for (uint8_t c = 0; c < INV_MAX_CHAN_COUNT; c++) { - config.Inverter[i].channel[c].MaxChannelPower = channels[c]; - strlcpy(config.Inverter[i].channel[c].Name, "", sizeof(config.Inverter[i].channel[c].Name)); - } - } - } - - if (config.Cfg_Version < 0x00011800) { - JsonObject mqtt = doc["mqtt"]; - config.Mqtt_PublishInterval = mqtt["publish_invterval"]; - } - - f.close(); - - config.Cfg_Version = CONFIG_VERSION; - write(); - read(); -} - -CONFIG_T& ConfigurationClass::get() -{ - return config; -} - -INVERTER_CONFIG_T* ConfigurationClass::getFreeInverterSlot() -{ - for (uint8_t i = 0; i < INV_MAX_COUNT; i++) { - if (config.Inverter[i].Serial == 0) { - return &config.Inverter[i]; - } - } - - return NULL; -} - -INVERTER_CONFIG_T* ConfigurationClass::getInverterConfig(uint64_t serial) -{ - for (uint8_t i = 0; i < INV_MAX_COUNT; i++) { - if (config.Inverter[i].Serial == serial) { - return &config.Inverter[i]; - } - } - - return NULL; -} - -ConfigurationClass Configuration; +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2022 Thomas Basler and others + */ +#include "Configuration.h" +#include "MessageOutput.h" +#include "defaults.h" +#include +#include + +CONFIG_T config; + +void ConfigurationClass::init() +{ + memset(&config, 0x0, sizeof(config)); +} + +bool ConfigurationClass::write() +{ + File f = LittleFS.open(CONFIG_FILENAME, "w"); + if (!f) { + return false; + } + config.Cfg_SaveCount++; + + DynamicJsonDocument doc(JSON_BUFFER_SIZE); + + JsonObject cfg = doc.createNestedObject("cfg"); + cfg["version"] = config.Cfg_Version; + cfg["save_count"] = config.Cfg_SaveCount; + + JsonObject wifi = doc.createNestedObject("wifi"); + wifi["ssid"] = config.WiFi_Ssid; + wifi["password"] = config.WiFi_Password; + wifi["ip"] = IPAddress(config.WiFi_Ip).toString(); + wifi["netmask"] = IPAddress(config.WiFi_Netmask).toString(); + wifi["gateway"] = IPAddress(config.WiFi_Gateway).toString(); + wifi["dns1"] = IPAddress(config.WiFi_Dns1).toString(); + wifi["dns2"] = IPAddress(config.WiFi_Dns2).toString(); + wifi["dhcp"] = config.WiFi_Dhcp; + wifi["hostname"] = config.WiFi_Hostname; + + JsonObject ntp = doc.createNestedObject("ntp"); + ntp["server"] = config.Ntp_Server; + ntp["timezone"] = config.Ntp_Timezone; + ntp["timezone_descr"] = config.Ntp_TimezoneDescr; + ntp["latitude"] = config.Ntp_Latitude; + ntp["longitude"] = config.Ntp_Longitude; + + JsonObject mqtt = doc.createNestedObject("mqtt"); + mqtt["enabled"] = config.Mqtt_Enabled; + mqtt["hostname"] = config.Mqtt_Hostname; + mqtt["port"] = config.Mqtt_Port; + mqtt["username"] = config.Mqtt_Username; + mqtt["password"] = config.Mqtt_Password; + mqtt["topic"] = config.Mqtt_Topic; + mqtt["retain"] = config.Mqtt_Retain; + mqtt["publish_interval"] = config.Mqtt_PublishInterval; + + JsonObject mqtt_lwt = mqtt.createNestedObject("lwt"); + mqtt_lwt["topic"] = config.Mqtt_LwtTopic; + mqtt_lwt["value_online"] = config.Mqtt_LwtValue_Online; + mqtt_lwt["value_offline"] = config.Mqtt_LwtValue_Offline; + + JsonObject mqtt_tls = mqtt.createNestedObject("tls"); + mqtt_tls["enabled"] = config.Mqtt_Tls; + mqtt_tls["root_ca_cert"] = config.Mqtt_RootCaCert; + + JsonObject mqtt_hass = mqtt.createNestedObject("hass"); + mqtt_hass["enabled"] = config.Mqtt_Hass_Enabled; + mqtt_hass["retain"] = config.Mqtt_Hass_Retain; + mqtt_hass["topic"] = config.Mqtt_Hass_Topic; + mqtt_hass["individual_panels"] = config.Mqtt_Hass_IndividualPanels; + mqtt_hass["expire"] = config.Mqtt_Hass_Expire; + + JsonObject dtu = doc.createNestedObject("dtu"); + dtu["serial"] = config.Dtu_Serial; + dtu["poll_interval"] = config.Dtu_PollInterval; + dtu["pa_level"] = config.Dtu_PaLevel; + + JsonObject security = doc.createNestedObject("security"); + security["password"] = config.Security_Password; + security["allow_readonly"] = config.Security_AllowReadonly; + + JsonObject device = doc.createNestedObject("device"); + device["pinmapping"] = config.Dev_PinMapping; + + JsonObject display = device.createNestedObject("display"); + display["powersafe"] = config.Display_PowerSafe; + display["screensaver"] = config.Display_ScreenSaver; + display["rotation"] = config.Display_Rotation; + display["contrast"] = config.Display_Contrast; + + JsonArray inverters = doc.createNestedArray("inverters"); + for (uint8_t i = 0; i < INV_MAX_COUNT; i++) { + JsonObject inv = inverters.createNestedObject(); + inv["serial"] = config.Inverter[i].Serial; + inv["name"] = config.Inverter[i].Name; + inv["poll_enable"] = config.Inverter[i].Poll_Enable; + inv["poll_enable_night"] = config.Inverter[i].Poll_Enable_Night; + inv["command_enable"] = config.Inverter[i].Command_Enable; + inv["command_enable_night"] = config.Inverter[i].Command_Enable_Night; + + JsonArray channel = inv.createNestedArray("channel"); + for (uint8_t c = 0; c < INV_MAX_CHAN_COUNT; c++) { + JsonObject chanData = channel.createNestedObject(); + chanData["name"] = config.Inverter[i].channel[c].Name; + chanData["max_power"] = config.Inverter[i].channel[c].MaxChannelPower; + chanData["yield_total_offset"] = config.Inverter[i].channel[c].YieldTotalOffset; + } + } + + JsonObject vedirect = doc.createNestedObject("vedirect"); + vedirect["enabled"] = config.Vedirect_Enabled; + vedirect["updates_only"] = config.Vedirect_UpdatesOnly; + vedirect["poll_interval"] = config.Vedirect_PollInterval; + + JsonObject powermeter = doc.createNestedObject("powermeter"); + powermeter["enabled"] = config.PowerMeter_Enabled; + powermeter["interval"] = config.PowerMeter_Interval; + powermeter["source"] = config.PowerMeter_Source; + powermeter["mqtt_topic_powermeter_1"] = config.PowerMeter_MqttTopicPowerMeter1; + powermeter["mqtt_topic_powermeter_2"] = config.PowerMeter_MqttTopicPowerMeter2; + 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; + powerlimiter["solar_passtrough_enabled"] = config.PowerLimiter_SolarPassTroughEnabled; + powerlimiter["battery_drain_strategy"] = config.PowerLimiter_BatteryDrainStategy; + powerlimiter["interval"] = config.PowerLimiter_Interval; + powerlimiter["is_inverter_behind_powermeter"] = config.PowerLimiter_IsInverterBehindPowerMeter; + powerlimiter["inverter_id"] = config.PowerLimiter_InverterId; + powerlimiter["inverter_channel_id"] = config.PowerLimiter_InverterChannelId; + powerlimiter["target_power_consumption"] = config.PowerLimiter_TargetPowerConsumption; + powerlimiter["target_power_consumption_hysteresis"] = config.PowerLimiter_TargetPowerConsumptionHysteresis; + powerlimiter["lower_power_limit"] = config.PowerLimiter_LowerPowerLimit; + powerlimiter["upper_power_limit"] = config.PowerLimiter_UpperPowerLimit; + powerlimiter["battery_soc_start_threshold"] = config.PowerLimiter_BatterySocStartThreshold; + powerlimiter["battery_soc_stop_threshold"] = config.PowerLimiter_BatterySocStopThreshold; + powerlimiter["voltage_start_threshold"] = config.PowerLimiter_VoltageStartThreshold; + powerlimiter["voltage_stop_threshold"] = config.PowerLimiter_VoltageStopThreshold; + powerlimiter["voltage_load_correction_factor"] = config.PowerLimiter_VoltageLoadCorrectionFactor; + + JsonObject battery = doc.createNestedObject("battery"); + battery["enabled"] = config.Battery_Enabled; + + JsonObject huawei = doc.createNestedObject("huawei"); + huawei["enabled"] = config.Huawei_Enabled; + + // Serialize JSON to file + if (serializeJson(doc, f) == 0) { + MessageOutput.println("Failed to write file"); + return false; + } + + f.close(); + return true; +} + +bool ConfigurationClass::read() +{ + File f = LittleFS.open(CONFIG_FILENAME, "r", false); + + DynamicJsonDocument doc(JSON_BUFFER_SIZE); + // Deserialize the JSON document + DeserializationError error = deserializeJson(doc, f); + if (error) { + MessageOutput.println("Failed to read file, using default configuration"); + } + + JsonObject cfg = doc["cfg"]; + config.Cfg_Version = cfg["version"] | CONFIG_VERSION; + config.Cfg_SaveCount = cfg["save_count"] | 0; + + JsonObject wifi = doc["wifi"]; + strlcpy(config.WiFi_Ssid, wifi["ssid"] | WIFI_SSID, sizeof(config.WiFi_Ssid)); + strlcpy(config.WiFi_Password, wifi["password"] | WIFI_PASSWORD, sizeof(config.WiFi_Password)); + strlcpy(config.WiFi_Hostname, wifi["hostname"] | APP_HOSTNAME, sizeof(config.WiFi_Hostname)); + + IPAddress wifi_ip; + wifi_ip.fromString(wifi["ip"] | ""); + config.WiFi_Ip[0] = wifi_ip[0]; + config.WiFi_Ip[1] = wifi_ip[1]; + config.WiFi_Ip[2] = wifi_ip[2]; + config.WiFi_Ip[3] = wifi_ip[3]; + + IPAddress wifi_netmask; + wifi_netmask.fromString(wifi["netmask"] | ""); + config.WiFi_Netmask[0] = wifi_netmask[0]; + config.WiFi_Netmask[1] = wifi_netmask[1]; + config.WiFi_Netmask[2] = wifi_netmask[2]; + config.WiFi_Netmask[3] = wifi_netmask[3]; + + IPAddress wifi_gateway; + wifi_gateway.fromString(wifi["gateway"] | ""); + config.WiFi_Gateway[0] = wifi_gateway[0]; + config.WiFi_Gateway[1] = wifi_gateway[1]; + config.WiFi_Gateway[2] = wifi_gateway[2]; + config.WiFi_Gateway[3] = wifi_gateway[3]; + + IPAddress wifi_dns1; + wifi_dns1.fromString(wifi["dns1"] | ""); + config.WiFi_Dns1[0] = wifi_dns1[0]; + config.WiFi_Dns1[1] = wifi_dns1[1]; + config.WiFi_Dns1[2] = wifi_dns1[2]; + config.WiFi_Dns1[3] = wifi_dns1[3]; + + IPAddress wifi_dns2; + wifi_dns2.fromString(wifi["dns2"] | ""); + config.WiFi_Dns2[0] = wifi_dns2[0]; + config.WiFi_Dns2[1] = wifi_dns2[1]; + config.WiFi_Dns2[2] = wifi_dns2[2]; + config.WiFi_Dns2[3] = wifi_dns2[3]; + + config.WiFi_Dhcp = wifi["dhcp"] | WIFI_DHCP; + + JsonObject ntp = doc["ntp"]; + strlcpy(config.Ntp_Server, ntp["server"] | NTP_SERVER, sizeof(config.Ntp_Server)); + strlcpy(config.Ntp_Timezone, ntp["timezone"] | NTP_TIMEZONE, sizeof(config.Ntp_Timezone)); + strlcpy(config.Ntp_TimezoneDescr, ntp["timezone_descr"] | NTP_TIMEZONEDESCR, sizeof(config.Ntp_TimezoneDescr)); + config.Ntp_Latitude = ntp["latitude"] | NTP_LATITUDE; + config.Ntp_Longitude = ntp["longitude"] | NTP_LONGITUDE; + + JsonObject mqtt = doc["mqtt"]; + config.Mqtt_Enabled = mqtt["enabled"] | MQTT_ENABLED; + strlcpy(config.Mqtt_Hostname, mqtt["hostname"] | MQTT_HOST, sizeof(config.Mqtt_Hostname)); + config.Mqtt_Port = mqtt["port"] | MQTT_PORT; + strlcpy(config.Mqtt_Username, mqtt["username"] | MQTT_USER, sizeof(config.Mqtt_Username)); + strlcpy(config.Mqtt_Password, mqtt["password"] | MQTT_PASSWORD, sizeof(config.Mqtt_Password)); + strlcpy(config.Mqtt_Topic, mqtt["topic"] | MQTT_TOPIC, sizeof(config.Mqtt_Topic)); + config.Mqtt_Retain = mqtt["retain"] | MQTT_RETAIN; + config.Mqtt_PublishInterval = mqtt["publish_interval"] | MQTT_PUBLISH_INTERVAL; + + JsonObject mqtt_lwt = mqtt["lwt"]; + strlcpy(config.Mqtt_LwtTopic, mqtt_lwt["topic"] | MQTT_LWT_TOPIC, sizeof(config.Mqtt_LwtTopic)); + strlcpy(config.Mqtt_LwtValue_Online, mqtt_lwt["value_online"] | MQTT_LWT_ONLINE, sizeof(config.Mqtt_LwtValue_Online)); + strlcpy(config.Mqtt_LwtValue_Offline, mqtt_lwt["value_offline"] | MQTT_LWT_OFFLINE, sizeof(config.Mqtt_LwtValue_Offline)); + + JsonObject mqtt_tls = mqtt["tls"]; + config.Mqtt_Tls = mqtt_tls["enabled"] | MQTT_TLS; + strlcpy(config.Mqtt_RootCaCert, mqtt_tls["root_ca_cert"] | MQTT_ROOT_CA_CERT, sizeof(config.Mqtt_RootCaCert)); + + JsonObject mqtt_hass = mqtt["hass"]; + config.Mqtt_Hass_Enabled = mqtt_hass["enabled"] | MQTT_HASS_ENABLED; + config.Mqtt_Hass_Retain = mqtt_hass["retain"] | MQTT_HASS_RETAIN; + config.Mqtt_Hass_Expire = mqtt_hass["expire"] | MQTT_HASS_EXPIRE; + config.Mqtt_Hass_IndividualPanels = mqtt_hass["individual_panels"] | MQTT_HASS_INDIVIDUALPANELS; + strlcpy(config.Mqtt_Hass_Topic, mqtt_hass["topic"] | MQTT_HASS_TOPIC, sizeof(config.Mqtt_Hass_Topic)); + + JsonObject dtu = doc["dtu"]; + config.Dtu_Serial = dtu["serial"] | DTU_SERIAL; + config.Dtu_PollInterval = dtu["poll_interval"] | DTU_POLL_INTERVAL; + config.Dtu_PaLevel = dtu["pa_level"] | DTU_PA_LEVEL; + + JsonObject security = doc["security"]; + strlcpy(config.Security_Password, security["password"] | ACCESS_POINT_PASSWORD, sizeof(config.Security_Password)); + config.Security_AllowReadonly = security["allow_readonly"] | SECURITY_ALLOW_READONLY; + + JsonObject device = doc["device"]; + strlcpy(config.Dev_PinMapping, device["pinmapping"] | DEV_PINMAPPING, sizeof(config.Dev_PinMapping)); + + JsonObject display = device["display"]; + config.Display_PowerSafe = display["powersafe"] | DISPLAY_POWERSAFE; + config.Display_ScreenSaver = display["screensaver"] | DISPLAY_SCREENSAVER; + config.Display_Rotation = display["rotation"] | DISPLAY_ROTATION; + config.Display_Contrast = display["contrast"] | DISPLAY_CONTRAST; + + JsonArray inverters = doc["inverters"]; + for (uint8_t i = 0; i < INV_MAX_COUNT; i++) { + JsonObject inv = inverters[i].as(); + config.Inverter[i].Serial = inv["serial"] | 0ULL; + strlcpy(config.Inverter[i].Name, inv["name"] | "", sizeof(config.Inverter[i].Name)); + + config.Inverter[i].Poll_Enable = inv["poll_enable"] | true; + config.Inverter[i].Poll_Enable_Night = inv["poll_enable_night"] | true; + config.Inverter[i].Command_Enable = inv["command_enable"] | true; + config.Inverter[i].Command_Enable_Night = inv["command_enable_night"] | true; + + JsonArray channel = inv["channel"]; + for (uint8_t c = 0; c < INV_MAX_CHAN_COUNT; c++) { + config.Inverter[i].channel[c].MaxChannelPower = channel[c]["max_power"] | 0; + config.Inverter[i].channel[c].YieldTotalOffset = channel[c]["yield_total_offset"] | 0.0f; + strlcpy(config.Inverter[i].channel[c].Name, channel[c]["name"] | "", sizeof(config.Inverter[i].channel[c].Name)); + } + } + + JsonObject vedirect = doc["vedirect"]; + config.Vedirect_Enabled = vedirect["enabled"] | VEDIRECT_ENABLED; + config.Vedirect_UpdatesOnly = vedirect["updates_only"] | VEDIRECT_UPDATESONLY; + config.Vedirect_PollInterval = vedirect["poll_interval"] | VEDIRECT_POLL_INTERVAL; + + JsonObject powermeter = doc["powermeter"]; + config.PowerMeter_Enabled = powermeter["enabled"] | POWERMETER_ENABLED; + config.PowerMeter_Interval = powermeter["interval"] | POWERMETER_INTERVAL; + config.PowerMeter_Source = powermeter["source"] | POWERMETER_SOURCE; + strlcpy(config.PowerMeter_MqttTopicPowerMeter1, powermeter["mqtt_topic_powermeter_1"] | "", sizeof(config.PowerMeter_MqttTopicPowerMeter1)); + strlcpy(config.PowerMeter_MqttTopicPowerMeter2, powermeter["mqtt_topic_powermeter_2"] | "", sizeof(config.PowerMeter_MqttTopicPowerMeter2)); + 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; + config.PowerLimiter_SolarPassTroughEnabled = powerlimiter["solar_passtrough_enabled"] | POWERLIMITER_SOLAR_PASSTROUGH_ENABLED; + config.PowerLimiter_BatteryDrainStategy = powerlimiter["battery_drain_strategy"] | POWERLIMITER_BATTERY_DRAIN_STRATEGY; + config.PowerLimiter_Interval = POWERLIMITER_INTERVAL; + config.PowerLimiter_IsInverterBehindPowerMeter = powerlimiter["is_inverter_behind_powermeter"] | POWERLIMITER_IS_INVERTER_BEHIND_POWER_METER; + config.PowerLimiter_InverterId = powerlimiter["inverter_id"] | POWERLIMITER_INVERTER_ID; + config.PowerLimiter_InverterChannelId = powerlimiter["inverter_channel_id"] | POWERLIMITER_INVERTER_CHANNEL_ID; + config.PowerLimiter_TargetPowerConsumption = powerlimiter["target_power_consumption"] | POWERLIMITER_TARGET_POWER_CONSUMPTION; + config.PowerLimiter_TargetPowerConsumptionHysteresis = powerlimiter["target_power_consumption_hysteresis"] | POWERLIMITER_TARGET_POWER_CONSUMPTION_HYSTERESIS; + config.PowerLimiter_LowerPowerLimit = powerlimiter["lower_power_limit"] | POWERLIMITER_LOWER_POWER_LIMIT; + config.PowerLimiter_UpperPowerLimit = powerlimiter["upper_power_limit"] | POWERLIMITER_UPPER_POWER_LIMIT; + config.PowerLimiter_BatterySocStartThreshold = powerlimiter["battery_soc_start_threshold"] | POWERLIMITER_BATTERY_SOC_START_THRESHOLD; + config.PowerLimiter_BatterySocStopThreshold = powerlimiter["battery_soc_stop_threshold"] | POWERLIMITER_BATTERY_SOC_STOP_THRESHOLD; + config.PowerLimiter_VoltageStartThreshold = powerlimiter["voltage_start_threshold"] | POWERLIMITER_VOLTAGE_START_THRESHOLD; + config.PowerLimiter_VoltageStopThreshold = powerlimiter["voltage_stop_threshold"] | POWERLIMITER_VOLTAGE_STOP_THRESHOLD; + config.PowerLimiter_VoltageLoadCorrectionFactor = powerlimiter["voltage_load_correction_factor"] | POWERLIMITER_VOLTAGE_LOAD_CORRECTION_FACTOR; + + JsonObject battery = doc["battery"]; + config.Battery_Enabled = battery["enabled"] | BATTERY_ENABLED; + + JsonObject huawei = doc["huawei"]; + config.Huawei_Enabled = huawei["enabled"] | HUAWEI_ENABLED; + + f.close(); + return true; +} + +void ConfigurationClass::migrate() +{ + File f = LittleFS.open(CONFIG_FILENAME, "r", false); + if (!f) { + MessageOutput.println("Failed to open file, cancel migration"); + return; + } + + DynamicJsonDocument doc(JSON_BUFFER_SIZE); + // Deserialize the JSON document + DeserializationError error = deserializeJson(doc, f); + if (error) { + MessageOutput.printf("Failed to read file, cancel migration: %s\r\n", error.c_str()); + return; + } + + if (config.Cfg_Version < 0x00011700) { + JsonArray inverters = doc["inverters"]; + for (uint8_t i = 0; i < INV_MAX_COUNT; i++) { + JsonObject inv = inverters[i].as(); + JsonArray channels = inv["channels"]; + for (uint8_t c = 0; c < INV_MAX_CHAN_COUNT; c++) { + config.Inverter[i].channel[c].MaxChannelPower = channels[c]; + strlcpy(config.Inverter[i].channel[c].Name, "", sizeof(config.Inverter[i].channel[c].Name)); + } + } + } + + if (config.Cfg_Version < 0x00011800) { + JsonObject mqtt = doc["mqtt"]; + config.Mqtt_PublishInterval = mqtt["publish_invterval"]; + } + + f.close(); + + config.Cfg_Version = CONFIG_VERSION; + write(); + read(); +} + +CONFIG_T& ConfigurationClass::get() +{ + return config; +} + +INVERTER_CONFIG_T* ConfigurationClass::getFreeInverterSlot() +{ + for (uint8_t i = 0; i < INV_MAX_COUNT; i++) { + if (config.Inverter[i].Serial == 0) { + return &config.Inverter[i]; + } + } + + return NULL; +} + +INVERTER_CONFIG_T* ConfigurationClass::getInverterConfig(uint64_t serial) +{ + for (uint8_t i = 0; i < INV_MAX_COUNT; i++) { + if (config.Inverter[i].Serial == serial) { + return &config.Inverter[i]; + } + } + + return NULL; +} + +ConfigurationClass Configuration; diff --git a/src/MqttHandlePowerLimiter.cpp b/src/MqttHandlePowerLimiter.cpp new file mode 100644 index 00000000..2830d420 --- /dev/null +++ b/src/MqttHandlePowerLimiter.cpp @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2022 Thomas Basler, Malte Schmidt and others + */ +#include "MessageOutput.h" +#include "MqttSettings.h" +#include "MqttHandlePowerLimiter.h" +#include "PowerLimiter.h" +#include + +#define TOPIC_SUB_POWER_LIMITER "disable" + +MqttHandlePowerLimiterClass MqttHandlePowerLimiter; + +void MqttHandlePowerLimiterClass::init() +{ + using std::placeholders::_1; + using std::placeholders::_2; + using std::placeholders::_3; + using std::placeholders::_4; + using std::placeholders::_5; + using std::placeholders::_6; + + String topic = MqttSettings.getPrefix(); + MqttSettings.subscribe(String(topic + "powerlimiter/cmd/" + TOPIC_SUB_POWER_LIMITER).c_str(), 0, std::bind(&MqttHandlePowerLimiterClass::onMqttMessage, this, _1, _2, _3, _4, _5, _6)); + + _lastPublish = millis(); + +} + + +void MqttHandlePowerLimiterClass::loop() +{ + if (!MqttSettings.getConnected() ) { + return; + } + + const CONFIG_T& config = Configuration.get(); + + if ((millis() - _lastPublish) > (config.Mqtt_PublishInterval * 1000) ) { + MqttSettings.publish("powerlimiter/status/disabled", String(PowerLimiter.getDisable())); + + yield(); + _lastPublish = millis(); + } +} + + +void MqttHandlePowerLimiterClass::onMqttMessage(const espMqttClientTypes::MessageProperties& properties, const char* topic, const uint8_t* payload, size_t len, size_t index, size_t total) +{ + const CONFIG_T& config = Configuration.get(); + + char token_topic[MQTT_MAX_TOPIC_STRLEN + 40]; // respect all subtopics + strncpy(token_topic, topic, MQTT_MAX_TOPIC_STRLEN + 40); // convert const char* to char* + + char* setting; + char* rest = &token_topic[strlen(config.Mqtt_Topic)]; + + strtok_r(rest, "/", &rest); // Remove "powerlimiter" + strtok_r(rest, "/", &rest); // Remove "cmd" + + setting = strtok_r(rest, "/", &rest); + + if (setting == NULL) { + return; + } + + char* strlimit = new char[len + 1]; + memcpy(strlimit, payload, len); + strlimit[len] = '\0'; + float payload_val = strtof(strlimit, NULL); + delete[] strlimit; + + if (!strcmp(setting, TOPIC_SUB_POWER_LIMITER)) { + MessageOutput.printf("Disable power limter: %f A\r\n", payload_val); + if(payload_val == 1) { + PowerLimiter.setDisable(true); + } + if(payload_val == 0) { + PowerLimiter.setDisable(false); + } + } +} \ No newline at end of file diff --git a/src/PowerLimiter.cpp b/src/PowerLimiter.cpp index 6cde83dd..38742324 100644 --- a/src/PowerLimiter.cpp +++ b/src/PowerLimiter.cpp @@ -24,11 +24,13 @@ void PowerLimiterClass::loop() CONFIG_T& config = Configuration.get(); if (!config.PowerLimiter_Enabled + || _disabled || !config.PowerMeter_Enabled || !Hoymiles.getRadio()->isIdle() || (millis() - _lastCommandSent) < (config.PowerLimiter_Interval * 1000) || (millis() - _lastLoop) < (config.PowerLimiter_Interval * 1000)) { - if (!config.PowerLimiter_Enabled) + if (!config.PowerLimiter_Enabled + || _disabled) _plState = STATE_DISCOVER; // ensure STATE_DISCOVER is set, if PowerLimiter will be enabled. return; } @@ -150,6 +152,14 @@ int32_t PowerLimiterClass::getLastRequestedPowewrLimit() { return _lastRequestedPowerLimit; } +bool PowerLimiterClass::getDisable() { + return _disabled; +} + +void PowerLimiterClass::setDisable(bool disable) { + _disabled = disable; +} + bool PowerLimiterClass::canUseDirectSolarPower() { CONFIG_T& config = Configuration.get(); diff --git a/src/WebApi_powerlimiter.cpp b/src/WebApi_powerlimiter.cpp index c1015240..dad80b7b 100644 --- a/src/WebApi_powerlimiter.cpp +++ b/src/WebApi_powerlimiter.cpp @@ -1,145 +1,145 @@ -// SPDX-License-Identifier: GPL-2.0-or-later -/* - * Copyright (C) 2022 Thomas Basler and others - */ -#include "WebApi_powerlimiter.h" -#include "VeDirectFrameHandler.h" -#include "ArduinoJson.h" -#include "AsyncJson.h" -#include "Configuration.h" -#include "MqttHandleHass.h" -#include "MqttHandleVedirectHass.h" -#include "MqttSettings.h" -#include "PowerMeter.h" -#include "PowerLimiter.h" -#include "WebApi.h" -#include "helper.h" -#include "WebApi_errors.h" - -void WebApiPowerLimiterClass::init(AsyncWebServer* server) -{ - using std::placeholders::_1; - - _server = server; - - _server->on("/api/powerlimiter/status", HTTP_GET, std::bind(&WebApiPowerLimiterClass::onStatus, this, _1)); - _server->on("/api/powerlimiter/config", HTTP_GET, std::bind(&WebApiPowerLimiterClass::onAdminGet, this, _1)); - _server->on("/api/powerlimiter/config", HTTP_POST, std::bind(&WebApiPowerLimiterClass::onAdminPost, this, _1)); -} - -void WebApiPowerLimiterClass::loop() -{ -} - -void WebApiPowerLimiterClass::onStatus(AsyncWebServerRequest* request) -{ - AsyncJsonResponse* response = new AsyncJsonResponse(); - JsonObject root = response->getRoot(); - const CONFIG_T& config = Configuration.get(); - - root[F("enabled")] = config.PowerLimiter_Enabled; - root[F("solar_passtrough_enabled")] = config.PowerLimiter_SolarPassTroughEnabled; - root[F("battery_drain_strategy")] = config.PowerLimiter_BatteryDrainStategy; - root[F("is_inverter_behind_powermeter")] = config.PowerLimiter_IsInverterBehindPowerMeter; - root[F("inverter_id")] = config.PowerLimiter_InverterId; - root[F("inverter_channel_id")] = config.PowerLimiter_InverterChannelId; - root[F("target_power_consumption")] = config.PowerLimiter_TargetPowerConsumption; - root[F("target_power_consumption_hysteresis")] = config.PowerLimiter_TargetPowerConsumptionHysteresis; - root[F("lower_power_limit")] = config.PowerLimiter_LowerPowerLimit; - root[F("upper_power_limit")] = config.PowerLimiter_UpperPowerLimit; - root[F("battery_soc_start_threshold")] = config.PowerLimiter_BatterySocStartThreshold; - root[F("battery_soc_stop_threshold")] = config.PowerLimiter_BatterySocStopThreshold; - root[F("voltage_start_threshold")] = static_cast(config.PowerLimiter_VoltageStartThreshold * 100 +0.5) / 100.0; - root[F("voltage_stop_threshold")] = static_cast(config.PowerLimiter_VoltageStopThreshold * 100 +0.5) / 100.0;; - root[F("voltage_load_correction_factor")] = config.PowerLimiter_VoltageLoadCorrectionFactor; - - response->setLength(); - request->send(response); -} - -void WebApiPowerLimiterClass::onAdminGet(AsyncWebServerRequest* request) -{ - if (!WebApi.checkCredentials(request)) { - return; - } - - this->onStatus(request); -} - -void WebApiPowerLimiterClass::onAdminPost(AsyncWebServerRequest* request) -{ - if (!WebApi.checkCredentials(request)) { - return; - } - - AsyncJsonResponse* response = new AsyncJsonResponse(); - JsonObject retMsg = response->getRoot(); - retMsg[F("type")] = F("warning"); - - if (!request->hasParam("data", true)) { - retMsg[F("message")] = F("No values found!"); - response->setLength(); - request->send(response); - return; - } - - String json = request->getParam("data", true)->value(); - - if (json.length() > 1024) { - retMsg[F("message")] = F("Data too large!"); - response->setLength(); - request->send(response); - return; - } - - DynamicJsonDocument root(1024); - DeserializationError error = deserializeJson(root, json); - - if (error) { - retMsg[F("message")] = F("Failed to parse data!"); - response->setLength(); - request->send(response); - return; - } - - if (!(root.containsKey("enabled") - && root.containsKey("lower_power_limit") - && root.containsKey("inverter_id") - && root.containsKey("inverter_channel_id") - && root.containsKey("target_power_consumption") - && root.containsKey("target_power_consumption_hysteresis") - )) { - retMsg[F("message")] = F("Values are missing!"); - retMsg[F("code")] = WebApiError::GenericValueMissing; - response->setLength(); - request->send(response); - return; - } - - - CONFIG_T& config = Configuration.get(); - config.PowerLimiter_Enabled = root[F("enabled")].as(); - config.PowerLimiter_SolarPassTroughEnabled = root[F("solar_passtrough_enabled")].as(); - config.PowerLimiter_BatteryDrainStategy= root[F("battery_drain_strategy")].as(); - config.PowerLimiter_IsInverterBehindPowerMeter = root[F("is_inverter_behind_powermeter")].as(); - config.PowerLimiter_InverterId = root[F("inverter_id")].as(); - config.PowerLimiter_InverterChannelId = root[F("inverter_channel_id")].as(); - config.PowerLimiter_TargetPowerConsumption = root[F("target_power_consumption")].as(); - config.PowerLimiter_TargetPowerConsumptionHysteresis = root[F("target_power_consumption_hysteresis")].as(); - config.PowerLimiter_LowerPowerLimit = root[F("lower_power_limit")].as(); - config.PowerLimiter_UpperPowerLimit = root[F("upper_power_limit")].as(); - config.PowerLimiter_BatterySocStartThreshold = root[F("battery_soc_start_threshold")].as(); - config.PowerLimiter_BatterySocStopThreshold = root[F("battery_soc_stop_threshold")].as(); - config.PowerLimiter_VoltageStartThreshold = root[F("voltage_start_threshold")].as(); - config.PowerLimiter_VoltageStartThreshold = static_cast(config.PowerLimiter_VoltageStartThreshold * 100) / 100.0; - config.PowerLimiter_VoltageStopThreshold = root[F("voltage_stop_threshold")].as(); - config.PowerLimiter_VoltageStopThreshold = static_cast(config.PowerLimiter_VoltageStopThreshold * 100) / 100.0; - config.PowerLimiter_VoltageLoadCorrectionFactor = root[F("voltage_load_correction_factor")].as(); - Configuration.write(); - - retMsg[F("type")] = F("success"); - retMsg[F("message")] = F("Settings saved!"); - - response->setLength(); - request->send(response); -} +// SPDX-License-Identifier: GPL-2.0-or-later +/* + * Copyright (C) 2022 Thomas Basler and others + */ +#include "WebApi_powerlimiter.h" +#include "VeDirectFrameHandler.h" +#include "ArduinoJson.h" +#include "AsyncJson.h" +#include "Configuration.h" +#include "MqttHandleHass.h" +#include "MqttHandleVedirectHass.h" +#include "MqttSettings.h" +#include "PowerMeter.h" +#include "PowerLimiter.h" +#include "WebApi.h" +#include "helper.h" +#include "WebApi_errors.h" + +void WebApiPowerLimiterClass::init(AsyncWebServer* server) +{ + using std::placeholders::_1; + + _server = server; + + _server->on("/api/powerlimiter/status", HTTP_GET, std::bind(&WebApiPowerLimiterClass::onStatus, this, _1)); + _server->on("/api/powerlimiter/config", HTTP_GET, std::bind(&WebApiPowerLimiterClass::onAdminGet, this, _1)); + _server->on("/api/powerlimiter/config", HTTP_POST, std::bind(&WebApiPowerLimiterClass::onAdminPost, this, _1)); +} + +void WebApiPowerLimiterClass::loop() +{ +} + +void WebApiPowerLimiterClass::onStatus(AsyncWebServerRequest* request) +{ + AsyncJsonResponse* response = new AsyncJsonResponse(); + JsonObject root = response->getRoot(); + const CONFIG_T& config = Configuration.get(); + + root[F("enabled")] = config.PowerLimiter_Enabled; + root[F("solar_passtrough_enabled")] = config.PowerLimiter_SolarPassTroughEnabled; + root[F("battery_drain_strategy")] = config.PowerLimiter_BatteryDrainStategy; + root[F("is_inverter_behind_powermeter")] = config.PowerLimiter_IsInverterBehindPowerMeter; + root[F("inverter_id")] = config.PowerLimiter_InverterId; + root[F("inverter_channel_id")] = config.PowerLimiter_InverterChannelId; + root[F("target_power_consumption")] = config.PowerLimiter_TargetPowerConsumption; + root[F("target_power_consumption_hysteresis")] = config.PowerLimiter_TargetPowerConsumptionHysteresis; + root[F("lower_power_limit")] = config.PowerLimiter_LowerPowerLimit; + root[F("upper_power_limit")] = config.PowerLimiter_UpperPowerLimit; + root[F("battery_soc_start_threshold")] = config.PowerLimiter_BatterySocStartThreshold; + root[F("battery_soc_stop_threshold")] = config.PowerLimiter_BatterySocStopThreshold; + root[F("voltage_start_threshold")] = static_cast(config.PowerLimiter_VoltageStartThreshold * 100 +0.5) / 100.0; + root[F("voltage_stop_threshold")] = static_cast(config.PowerLimiter_VoltageStopThreshold * 100 +0.5) / 100.0;; + root[F("voltage_load_correction_factor")] = config.PowerLimiter_VoltageLoadCorrectionFactor; + + response->setLength(); + request->send(response); +} + +void WebApiPowerLimiterClass::onAdminGet(AsyncWebServerRequest* request) +{ + if (!WebApi.checkCredentials(request)) { + return; + } + + this->onStatus(request); +} + +void WebApiPowerLimiterClass::onAdminPost(AsyncWebServerRequest* request) +{ + if (!WebApi.checkCredentials(request)) { + return; + } + + AsyncJsonResponse* response = new AsyncJsonResponse(); + JsonObject retMsg = response->getRoot(); + retMsg[F("type")] = F("warning"); + + if (!request->hasParam("data", true)) { + retMsg[F("message")] = F("No values found!"); + response->setLength(); + request->send(response); + return; + } + + String json = request->getParam("data", true)->value(); + + if (json.length() > 1024) { + retMsg[F("message")] = F("Data too large!"); + response->setLength(); + request->send(response); + return; + } + + DynamicJsonDocument root(1024); + DeserializationError error = deserializeJson(root, json); + + if (error) { + retMsg[F("message")] = F("Failed to parse data!"); + response->setLength(); + request->send(response); + return; + } + + if (!(root.containsKey("enabled") + && root.containsKey("lower_power_limit") + && root.containsKey("inverter_id") + && root.containsKey("inverter_channel_id") + && root.containsKey("target_power_consumption") + && root.containsKey("target_power_consumption_hysteresis") + )) { + retMsg[F("message")] = F("Values are missing!"); + retMsg[F("code")] = WebApiError::GenericValueMissing; + response->setLength(); + request->send(response); + return; + } + + + CONFIG_T& config = Configuration.get(); + config.PowerLimiter_Enabled = root[F("enabled")].as(); + config.PowerLimiter_SolarPassTroughEnabled = root[F("solar_passtrough_enabled")].as(); + config.PowerLimiter_BatteryDrainStategy= root[F("battery_drain_strategy")].as(); + config.PowerLimiter_IsInverterBehindPowerMeter = root[F("is_inverter_behind_powermeter")].as(); + config.PowerLimiter_InverterId = root[F("inverter_id")].as(); + config.PowerLimiter_InverterChannelId = root[F("inverter_channel_id")].as(); + config.PowerLimiter_TargetPowerConsumption = root[F("target_power_consumption")].as(); + config.PowerLimiter_TargetPowerConsumptionHysteresis = root[F("target_power_consumption_hysteresis")].as(); + config.PowerLimiter_LowerPowerLimit = root[F("lower_power_limit")].as(); + config.PowerLimiter_UpperPowerLimit = root[F("upper_power_limit")].as(); + config.PowerLimiter_BatterySocStartThreshold = root[F("battery_soc_start_threshold")].as(); + config.PowerLimiter_BatterySocStopThreshold = root[F("battery_soc_stop_threshold")].as(); + config.PowerLimiter_VoltageStartThreshold = root[F("voltage_start_threshold")].as(); + config.PowerLimiter_VoltageStartThreshold = static_cast(config.PowerLimiter_VoltageStartThreshold * 100) / 100.0; + config.PowerLimiter_VoltageStopThreshold = root[F("voltage_stop_threshold")].as(); + config.PowerLimiter_VoltageStopThreshold = static_cast(config.PowerLimiter_VoltageStopThreshold * 100) / 100.0; + config.PowerLimiter_VoltageLoadCorrectionFactor = root[F("voltage_load_correction_factor")].as(); + Configuration.write(); + + retMsg[F("type")] = F("success"); + retMsg[F("message")] = F("Settings saved!"); + + response->setLength(); + request->send(response); +} From b6edc11eb2e72f575fdd19b962e4883c2129b90d Mon Sep 17 00:00:00 2001 From: MalteSchm Date: Wed, 12 Apr 2023 06:46:38 +0200 Subject: [PATCH 9/9] adding option to disable power limiter via mqtt - adding missing file --- include/MqttHandlePowerLimiter.h | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 include/MqttHandlePowerLimiter.h diff --git a/include/MqttHandlePowerLimiter.h b/include/MqttHandlePowerLimiter.h new file mode 100644 index 00000000..82d736ea --- /dev/null +++ b/include/MqttHandlePowerLimiter.h @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-2.0-or-later +#pragma once + +#include "Configuration.h" +#include + +class MqttHandlePowerLimiterClass { +public: + void init(); + void loop(); + +private: + void onMqttMessage(const espMqttClientTypes::MessageProperties& properties, const char* topic, const uint8_t* payload, size_t len, size_t index, size_t total); + + uint32_t _lastPublishStats; + uint32_t _lastPublish; + +}; + +extern MqttHandlePowerLimiterClass MqttHandlePowerLimiter; \ No newline at end of file