Browse Source

Merge branch 'development03' into eth

pull/1093/head
lumapu 2 years ago
parent
commit
0be01a91a1
  1. 8
      Getting_Started.md
  2. 13
      User_Manual.md
  3. 11
      src/CHANGES.md
  4. 21
      src/app.cpp
  5. 2
      src/defines.h
  6. 19
      src/hm/hmPayload.h
  7. 10
      src/platformio.ini
  8. 48
      src/plugins/Display/Display.h
  9. 157
      src/plugins/Display/Display_Mono.cpp
  10. 43
      src/plugins/Display/Display_Mono.h
  11. 155
      src/plugins/Display/Display_Mono_128X32.h
  12. 138
      src/plugins/Display/Display_Mono_128X64.h
  13. 132
      src/plugins/Display/Display_Mono_84X48.h
  14. 51
      src/plugins/Display/Display_ePaper.cpp
  15. 4
      src/plugins/Display/Display_ePaper.h
  16. 2
      src/publisher/pubMqtt.h
  17. 2
      src/web/RestApi.h
  18. 4
      src/web/html/setup.html
  19. 176
      src/web/web.h
  20. 3
      tools/rpi/ahoy.yml.example
  21. 29
      tools/rpi/hoymiles/__init__.py
  22. 10
      tools/rpi/hoymiles/__main__.py
  23. 8
      tools/rpi/hoymiles/decoders/__init__.py

8
Getting_Started.md

@ -217,6 +217,14 @@ Once your Ahoy DTU is running, you can use the Over The Air (OTA) capabilities t
! ATTENTION: If you update from a very low version to the newest, please make sure to wipe all flash data! ! ATTENTION: If you update from a very low version to the newest, please make sure to wipe all flash data!
#### Flashing on Linux with `esptool.py` (ESP32)
1. install [esptool.py](https://docs.espressif.com/projects/esptool/en/latest/esp32/) if you haven't already.
2. download and extract the latest release bin-file from [ahoy_](https://github.com/grindylow/ahoy/releases)
3. `cd ahoy_v<XXX> && cp *esp32.bin esp32.bin`
4. Perhaps you need to replace `/dev/ttyUSB0` to match your acual device in the following command. Execute it afterwards: `esptool.py --port /dev/ttyUSB0 --chip esp32 --before default_reset --after hard_reset write_flash --flash_mode dout --flash_freq 40m --flash_size detect 0x1000 bootloader.bin 0x8000 partitions.bin 0x10000 esp32.bin`
5. Unplug and replug your device.
6. Open a serial monitor (e.g. Putty) @ 115200 Baud. You should see some messages regarding wifi.
## Connect to your Ahoy DTU ## Connect to your Ahoy DTU
When everything is wired up and the firmware is flashed, it is time to connect to your Ahoy DTU. When everything is wired up and the firmware is flashed, it is time to connect to your Ahoy DTU.

13
User_Manual.md

@ -321,6 +321,19 @@ Send Power Limit:
- A persistent limit is only needed if you want to throttle your inverter permanently or you can use it to set a start value on the battery, which is then always the switch-on limit when switching on, otherwise it would ramp up to 100% without regulation, which is continuous load is not healthy. - A persistent limit is only needed if you want to throttle your inverter permanently or you can use it to set a start value on the battery, which is then always the switch-on limit when switching on, otherwise it would ramp up to 100% without regulation, which is continuous load is not healthy.
- You can set a new limit in the turn-off state, which is then used for on (switching on again), otherwise the last limit from before the turn-off is used, but of course this only applies if DC voltage is applied the whole time. - You can set a new limit in the turn-off state, which is then used for on (switching on again), otherwise the last limit from before the turn-off is used, but of course this only applies if DC voltage is applied the whole time.
- If the DC voltage is missing for a few seconds, the microcontroller in the inverter goes off and forgets everything that was temporary/non-persistent in the RAM: YieldDay, error memory, non-persistent limit. - If the DC voltage is missing for a few seconds, the microcontroller in the inverter goes off and forgets everything that was temporary/non-persistent in the RAM: YieldDay, error memory, non-persistent limit.
### Update your AHOY-DTU Firmware
To update your AHOY-DTU, you have to download the latest firmware package.
Here are the [latest stable releases](https://github.com/lumapu/ahoy/releases/) and [latest development builds](https://nightly.link/lumapu/ahoy/workflows/compile_development/development03/ahoydtu_dev.zip) available for download.
As soon as you have downloaded the firmware package, unzip it. On the WebUI, navigate to Update and press on select firmware file.
From the unzipped files, select the right .bin file for your hardware and needs.
- If you use an ESP8266, select the file ending with esp8266.bin
- If you use an ESP8266 with prometheus, select the file ending with esp8266_prometheus.bin
- If you use an ESP32, select the file ending with esp32.bin
- If you use an ESP32 with prometheus, select the file ending with esp32_prometheus.bin
Note: if you want to use prometheus, the usage of an ESP32 is recommended, since the ESP8266 is at its performance limits and therefore can cause stability issues.
After selecting the right firmware file, press update. Your AHOY-DTU will now install the new firmware and reboot.
## Additional Notes ## Additional Notes
### MI Inverters ### MI Inverters

11
src/CHANGES.md

@ -1,5 +1,16 @@
# Development Changes # Development Changes
## 0.6.15 - 2023-05-25
* improved Prometheus Endpoint PR #958
* fix turn off ePaper only if setting was set #956
* improved reset values and update MqTT #957
## 0.6.14 - 2023-05-21
* merge PR #902 Mono-Display
## 0.6.13 - 2023-05-16
* merge PR #934 (fix JSON API) and #944 (update manual)
## 0.6.12 - 2023-04-28 ## 0.6.12 - 2023-04-28
* improved MqTT * improved MqTT
* fix menu active item * fix menu active item

21
src/app.cpp

@ -325,6 +325,7 @@ void app::tickComm(void) {
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
void app::tickZeroValues(void) { void app::tickZeroValues(void) {
Inverter<> *iv; Inverter<> *iv;
bool changed = false;
// set values to zero, except yields // set values to zero, except yields
for (uint8_t id = 0; id < mSys.getNumInverters(); id++) { for (uint8_t id = 0; id < mSys.getNumInverters(); id++) {
iv = mSys.getInverterByPos(id); iv = mSys.getInverterByPos(id);
@ -332,7 +333,11 @@ void app::tickZeroValues(void) {
continue; // skip to next inverter continue; // skip to next inverter
mPayload.zeroInverterValues(iv); mPayload.zeroInverterValues(iv);
changed = true;
} }
if(changed)
payloadEventListener(RealTimeRunData_Debug);
} }
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
@ -340,15 +345,21 @@ void app::tickMinute(void) {
// only triggered if 'reset values on no avail is enabled' // only triggered if 'reset values on no avail is enabled'
Inverter<> *iv; Inverter<> *iv;
bool changed = false;
// set values to zero, except yields // set values to zero, except yields
for (uint8_t id = 0; id < mSys.getNumInverters(); id++) { for (uint8_t id = 0; id < mSys.getNumInverters(); id++) {
iv = mSys.getInverterByPos(id); iv = mSys.getInverterByPos(id);
if (NULL == iv) if (NULL == iv)
continue; // skip to next inverter continue; // skip to next inverter
if (!iv->isAvailable(mTimestamp) && !iv->isProducing(mTimestamp) && iv->config->enabled) if (!iv->isAvailable(mTimestamp) && !iv->isProducing(mTimestamp) && iv->config->enabled) {
mPayload.zeroInverterValues(iv); mPayload.zeroInverterValues(iv);
changed = true;
}
} }
if(changed)
payloadEventListener(RealTimeRunData_Debug);
} }
//----------------------------------------------------------------------------- //-----------------------------------------------------------------------------
@ -359,16 +370,20 @@ void app::tickMidnight(void) {
onceAt(std::bind(&app::tickMidnight, this), nxtTrig, "mid2"); onceAt(std::bind(&app::tickMidnight, this), nxtTrig, "mid2");
Inverter<> *iv; Inverter<> *iv;
bool changed = false;
// set values to zero, except yield total // set values to zero, except yield total
for (uint8_t id = 0; id < mSys.getNumInverters(); id++) { for (uint8_t id = 0; id < mSys.getNumInverters(); id++) {
iv = mSys.getInverterByPos(id); iv = mSys.getInverterByPos(id);
if (NULL == iv) if (NULL == iv)
continue; // skip to next inverter continue; // skip to next inverter
mPayload.zeroInverterValues(iv); mPayload.zeroInverterValues(iv, false);
mPayload.zeroYieldDay(iv); changed = true;
} }
if(changed)
payloadEventListener(RealTimeRunData_Debug);
if (mMqttEnabled) if (mMqttEnabled)
mMqtt.tickerMidnight(); mMqtt.tickerMidnight();
} }

2
src/defines.h

@ -13,7 +13,7 @@
//------------------------------------- //-------------------------------------
#define VERSION_MAJOR 0 #define VERSION_MAJOR 0
#define VERSION_MINOR 6 #define VERSION_MINOR 6
#define VERSION_PATCH 12 #define VERSION_PATCH 15
//------------------------------------- //-------------------------------------
typedef struct { typedef struct {

19
src/hm/hmPayload.h

@ -93,17 +93,7 @@ class HmPayload {
notify(0x0b); notify(0x0b);
}*/ }*/
void zeroYieldDay(Inverter<> *iv) { void zeroInverterValues(Inverter<> *iv, bool skipYieldDay = true) {
DPRINTLN(DBG_DEBUG, F("zeroYieldDay"));
record_t<> *rec = iv->getRecordStruct(RealTimeRunData_Debug);
uint8_t pos;
for(uint8_t ch = 0; ch <= iv->channels; ch++) {
pos = iv->getPosByChFld(ch, FLD_YD, rec);
iv->setValue(pos, rec, 0.0f);
}
}
void zeroInverterValues(Inverter<> *iv) {
DPRINTLN(DBG_DEBUG, F("zeroInverterValues")); DPRINTLN(DBG_DEBUG, F("zeroInverterValues"));
record_t<> *rec = iv->getRecordStruct(RealTimeRunData_Debug); record_t<> *rec = iv->getRecordStruct(RealTimeRunData_Debug);
for(uint8_t ch = 0; ch <= iv->channels; ch++) { for(uint8_t ch = 0; ch <= iv->channels; ch++) {
@ -111,15 +101,18 @@ class HmPayload {
for(uint8_t fld = 0; fld < FLD_EVT; fld++) { for(uint8_t fld = 0; fld < FLD_EVT; fld++) {
switch(fld) { switch(fld) {
case FLD_YD: case FLD_YD:
if(skipYieldDay)
continue;
else
break;
case FLD_YT: case FLD_YT:
continue; continue;
} }
pos = iv->getPosByChFld(ch, fld, rec); pos = iv->getPosByChFld(ch, fld, rec);
iv->setValue(pos, rec, 0.0f); iv->setValue(pos, rec, 0.0f);
} }
iv->doCalculations();
} }
notify(RealTimeRunData_Debug);
} }
void ivSendHighPrio(Inverter<> *iv) { void ivSendHighPrio(Inverter<> *iv) {

10
src/platformio.ini

@ -30,7 +30,7 @@ lib_deps =
bblanchon/ArduinoJson @ ^6.21.2 bblanchon/ArduinoJson @ ^6.21.2
https://github.com/JChristensen/Timezone @ ^1.2.4 https://github.com/JChristensen/Timezone @ ^1.2.4
olikraus/U8g2 @ ^2.34.17 olikraus/U8g2 @ ^2.34.17
zinggjm/GxEPD2 @ ^1.5.0 zinggjm/GxEPD2 @ ^1.5.2
[env:esp8266-release] [env:esp8266-release]
@ -98,7 +98,7 @@ monitor_filters =
log2file ; Log data to a file “platformio-device-monitor-*.log” located in the current working directory log2file ; Log data to a file “platformio-device-monitor-*.log” located in the current working directory
[env:esp32-wroom32-release] [env:esp32-wroom32-release]
platform = espressif32@>=6.1.0 platform = espressif32@6.1.0
board = lolin_d32 board = lolin_d32
build_flags = -D RELEASE -std=gnu++17 build_flags = -D RELEASE -std=gnu++17
build_unflags = -std=gnu++11 build_unflags = -std=gnu++11
@ -109,7 +109,7 @@ monitor_filters =
esp32_exception_decoder esp32_exception_decoder
[env:esp32-wroom32-release-prometheus] [env:esp32-wroom32-release-prometheus]
platform = espressif32@>=6.1.0 platform = espressif32@6.1.0
board = lolin_d32 board = lolin_d32
build_flags = -D RELEASE build_flags = -D RELEASE
-std=gnu++17 -std=gnu++17
@ -122,7 +122,7 @@ monitor_filters =
esp32_exception_decoder esp32_exception_decoder
[env:esp32-wroom32-debug] [env:esp32-wroom32-debug]
platform = espressif32@>=6.1.0 platform = espressif32@6.1.0
board = lolin_d32 board = lolin_d32
build_flags = -DDEBUG_LEVEL=DBG_DEBUG build_flags = -DDEBUG_LEVEL=DBG_DEBUG
-DDEBUG_ESP_CORE -DDEBUG_ESP_CORE
@ -166,7 +166,7 @@ monitor_filters =
esp32_exception_decoder esp32_exception_decoder
[env:opendtufusionv1-release] [env:opendtufusionv1-release]
platform = espressif32@>=6.1.0 platform = espressif32@6.1.0
board = esp32-s3-devkitc-1 board = esp32-s3-devkitc-1
upload_protocol = esp-builtin upload_protocol = esp-builtin
upload_speed = 115200 upload_speed = 115200

48
src/plugins/Display/Display.h

@ -7,6 +7,9 @@
#include "../../hm/hmSystem.h" #include "../../hm/hmSystem.h"
#include "../../utils/helper.h" #include "../../utils/helper.h"
#include "Display_Mono.h" #include "Display_Mono.h"
#include "Display_Mono_128X32.h"
#include "Display_Mono_128X64.h"
#include "Display_Mono_84X48.h"
#include "Display_ePaper.h" #include "Display_ePaper.h"
template <class HMSYSTEM> template <class HMSYSTEM>
@ -26,14 +29,27 @@ class Display {
return; return;
if ((0 < mCfg->type) && (mCfg->type < 10)) { if ((0 < mCfg->type) && (mCfg->type < 10)) {
mMono.config(mCfg->pwrSaveAtIvOffline, mCfg->pxShift, mCfg->contrast); switch (mCfg->type) {
mMono.init(mCfg->type, mCfg->rot, mCfg->disp_cs, mCfg->disp_dc, 0xff, mCfg->disp_clk, mCfg->disp_data, mUtcTs, mVersion); case 2:
case 1:
default:
mMono = new DisplayMono128X64();
break;
case 3:
mMono = new DisplayMono84X48();
break;
case 4:
mMono = new DisplayMono128X32();
break;
}
mMono->config(mCfg->pwrSaveAtIvOffline, mCfg->pxShift, mCfg->contrast);
mMono->init(mCfg->type, mCfg->rot, mCfg->disp_cs, mCfg->disp_dc, 0xff, mCfg->disp_clk, mCfg->disp_data, mUtcTs, mVersion);
} else if (mCfg->type >= 10) { } else if (mCfg->type >= 10) {
#if defined(ESP32) #if defined(ESP32)
mRefreshCycle = 0; mRefreshCycle = 0;
mEpaper.config(mCfg->rot); mEpaper.config(mCfg->rot, mCfg->pwrSaveAtIvOffline);
mEpaper.init(mCfg->type, mCfg->disp_cs, mCfg->disp_dc, mCfg->disp_reset, mCfg->disp_busy, mCfg->disp_clk, mCfg->disp_data, mUtcTs, mVersion); mEpaper.init(mCfg->type, mCfg->disp_cs, mCfg->disp_dc, mCfg->disp_reset, mCfg->disp_busy, mCfg->disp_clk, mCfg->disp_data, mUtcTs, mVersion);
#endif #endif
} }
} }
@ -42,7 +58,9 @@ class Display {
} }
void tickerSecond() { void tickerSecond() {
mMono.loop(); if (mMono != NULL)
mMono->loop();
if (mNewPayload || ((++mLoopCnt % 10) == 0)) { if (mNewPayload || ((++mLoopCnt % 10) == 0)) {
mNewPayload = false; mNewPayload = false;
mLoopCnt = 0; mLoopCnt = 0;
@ -79,21 +97,21 @@ class Display {
totalYieldTotal += iv->getChannelFieldValue(CH0, FLD_YT, rec); totalYieldTotal += iv->getChannelFieldValue(CH0, FLD_YT, rec);
} }
if ((0 < mCfg->type) && (mCfg->type < 10)) { if ((0 < mCfg->type) && (mCfg->type < 10) && (mMono != NULL)) {
mMono.disp(totalPower, totalYieldDay, totalYieldTotal, isprod); mMono->disp(totalPower, totalYieldDay, totalYieldTotal, isprod);
} else if (mCfg->type >= 10) { } else if (mCfg->type >= 10) {
#if defined(ESP32) #if defined(ESP32)
mEpaper.loop(totalPower, totalYieldDay, totalYieldTotal, isprod); mEpaper.loop(totalPower, totalYieldDay, totalYieldTotal, isprod);
mRefreshCycle++; mRefreshCycle++;
#endif #endif
} }
#if defined(ESP32) #if defined(ESP32)
if (mRefreshCycle > 480) { if (mRefreshCycle > 480) {
mEpaper.fullRefresh(); mEpaper.fullRefresh();
mRefreshCycle = 0; mRefreshCycle = 0;
} }
#endif #endif
} }
// private member variables // private member variables
@ -105,10 +123,10 @@ class Display {
HMSYSTEM *mSys; HMSYSTEM *mSys;
uint16_t mRefreshCycle; uint16_t mRefreshCycle;
#if defined(ESP32) #if defined(ESP32)
DisplayEPaper mEpaper; DisplayEPaper mEpaper;
#endif #endif
DisplayMono mMono; DisplayMono *mMono;
}; };
#endif /*__DISPLAY__*/ #endif /*__DISPLAY__*/

157
src/plugins/Display/Display_Mono.cpp

@ -1,157 +0,0 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include "Display_Mono.h"
#ifdef ESP8266
#include <ESP8266WiFi.h>
#elif defined(ESP32)
#include <WiFi.h>
#endif
#include "../../utils/helper.h"
//#ifdef U8X8_HAVE_HW_SPI
//#include <SPI.h>
//#endif
//#ifdef U8X8_HAVE_HW_I2C
//#include <Wire.h>
//#endif
DisplayMono::DisplayMono() {
mEnPowerSafe = true;
mEnScreenSaver = true;
mLuminance = 60;
_dispY = 0;
mTimeout = DISP_DEFAULT_TIMEOUT; // interval at which to power save (milliseconds)
mUtcTs = NULL;
mType = 0;
}
void DisplayMono::init(uint8_t type, uint8_t rotation, uint8_t cs, uint8_t dc, uint8_t reset, uint8_t clock, uint8_t data, uint32_t *utcTs, const char* version) {
if ((0 < type) && (type < 4)) {
u8g2_cb_t *rot = (u8g2_cb_t *)((rotation != 0x00) ? U8G2_R2 : U8G2_R0);
mType = type;
switch(type) {
case 1:
mDisplay = new U8G2_SSD1306_128X64_NONAME_F_HW_I2C(rot, reset, clock, data);
break;
default:
case 2:
mDisplay = new U8G2_SH1106_128X64_NONAME_F_HW_I2C(rot, reset, clock, data);
break;
case 3:
mDisplay = new U8G2_PCD8544_84X48_F_4W_SW_SPI(rot, clock, data, cs, dc, reset);
break;
}
mUtcTs = utcTs;
mDisplay->begin();
mIsLarge = (mDisplay->getWidth() > 120);
calcLineHeights();
mDisplay->clearBuffer();
if (3 != mType)
mDisplay->setContrast(mLuminance);
printText("AHOY!", 0, 35);
printText("ahoydtu.de", 2, 20);
printText(version, 3, 46);
mDisplay->sendBuffer();
}
}
void DisplayMono::config(bool enPowerSafe, bool enScreenSaver, uint8_t lum) {
mEnPowerSafe = enPowerSafe;
mEnScreenSaver = enScreenSaver;
mLuminance = lum;
}
void DisplayMono::loop(void) {
if (mEnPowerSafe)
if(mTimeout != 0)
mTimeout--;
}
void DisplayMono::disp(float totalPower, float totalYieldDay, float totalYieldTotal, uint8_t isprod) {
mDisplay->clearBuffer();
// set Contrast of the Display to raise the lifetime
if (3 != mType)
mDisplay->setContrast(mLuminance);
if ((totalPower > 0) && (isprod > 0)) {
mTimeout = DISP_DEFAULT_TIMEOUT;
mDisplay->setPowerSave(false);
if (totalPower > 999) {
snprintf(_fmtText, DISP_FMT_TEXT_LEN, "%2.2f kW", (totalPower / 1000));
} else {
snprintf(_fmtText, DISP_FMT_TEXT_LEN, "%3.0f W", totalPower);
}
printText(_fmtText, 0);
} else {
printText("offline", 0, 25);
// check if it's time to enter power saving mode
if (mTimeout == 0)
mDisplay->setPowerSave(mEnPowerSafe);
}
snprintf(_fmtText, DISP_FMT_TEXT_LEN, "today: %4.0f Wh", totalYieldDay);
printText(_fmtText, 1);
snprintf(_fmtText, DISP_FMT_TEXT_LEN, "total: %.1f kWh", totalYieldTotal);
printText(_fmtText, 2);
IPAddress ip = WiFi.localIP();
if (!(_mExtra % 10) && (ip)) {
printText(ip.toString().c_str(), 3);
} else if (!(_mExtra % 5)) {
snprintf(_fmtText, DISP_FMT_TEXT_LEN, "%d Inverter on", isprod);
printText(_fmtText, 3);
} else {
if(mIsLarge && (NULL != mUtcTs))
printText(ah::getDateTimeStr(gTimezone.toLocal(*mUtcTs)).c_str(), 3);
else
printText(ah::getTimeStr(gTimezone.toLocal(*mUtcTs)).c_str(), 3);
}
mDisplay->sendBuffer();
_dispY = 0;
_mExtra++;
}
void DisplayMono::calcLineHeights() {
uint8_t yOff = 0;
for (uint8_t i = 0; i < 4; i++) {
setFont(i);
yOff += (mDisplay->getMaxCharHeight());
mLineOffsets[i] = yOff;
}
}
inline void DisplayMono::setFont(uint8_t line) {
switch (line) {
case 0:
mDisplay->setFont((mIsLarge) ? u8g2_font_ncenB14_tr : u8g2_font_logisoso16_tr);
break;
case 3:
mDisplay->setFont(u8g2_font_5x8_tr);
break;
default:
mDisplay->setFont((mIsLarge) ? u8g2_font_ncenB10_tr : u8g2_font_5x8_tr);
break;
}
}
void DisplayMono::printText(const char* text, uint8_t line, uint8_t dispX) {
if (!mIsLarge) {
dispX = (line == 0) ? 10 : 5;
}
setFont(line);
dispX += (mEnScreenSaver) ? (_mExtra % 7) : 0;
mDisplay->drawStr(dispX, mLineOffsets[line], text);
}

43
src/plugins/Display/Display_Mono.h

@ -1,38 +1,45 @@
// SPDX-License-Identifier: GPL-2.0-or-later //-----------------------------------------------------------------------------
#pragma once // 2023 Ahoy, https://ahoydtu.de
// Creative Commons - https://creativecommons.org/licenses/by-nc-sa/4.0/deed
//-----------------------------------------------------------------------------
#pragma once
#include <U8g2lib.h> #include <U8g2lib.h>
#define DISP_DEFAULT_TIMEOUT 60 // in seconds #define DISP_DEFAULT_TIMEOUT 60 // in seconds
#define DISP_FMT_TEXT_LEN 32 #define DISP_FMT_TEXT_LEN 32
#define BOTTOM_MARGIN 5
#ifdef ESP8266
#include <ESP8266WiFi.h>
#elif defined(ESP32)
#include <WiFi.h>
#endif
#include "../../utils/helper.h"
class DisplayMono { class DisplayMono {
public: public:
DisplayMono(); DisplayMono() {};
void init(uint8_t type, uint8_t rot, uint8_t cs, uint8_t dc, uint8_t reset, uint8_t clock, uint8_t data, uint32_t *utcTs, const char* version);
void config(bool enPowerSafe, bool enScreenSaver, uint8_t lum);
void loop(void);
void disp(float totalPower, float totalYieldDay, float totalYieldTotal, uint8_t isprod);
private: virtual void init(uint8_t type, uint8_t rot, uint8_t cs, uint8_t dc, uint8_t reset, uint8_t clock, uint8_t data, uint32_t* utcTs, const char* version) = 0;
void calcLineHeights(); virtual void config(bool enPowerSafe, bool enScreenSaver, uint8_t lum) = 0;
void setFont(uint8_t line); virtual void loop(void) = 0;
void printText(const char* text, uint8_t line, uint8_t dispX = 5); virtual void disp(float totalPower, float totalYieldDay, float totalYieldTotal, uint8_t isprod) = 0;
protected:
U8G2* mDisplay; U8G2* mDisplay;
uint8_t mType; uint8_t mType;
bool mEnPowerSafe, mEnScreenSaver; bool mEnPowerSafe, mEnScreenSaver;
uint8_t mLuminance; uint8_t mLuminance;
bool mIsLarge = false;
uint8_t mLoopCnt; uint8_t mLoopCnt;
uint32_t* mUtcTs; uint32_t* mUtcTs;
uint8_t mLineOffsets[5]; uint8_t mLineXOffsets[5];
uint8_t mLineYOffsets[5];
uint16_t _dispY; uint16_t mDispY;
uint8_t _mExtra; uint8_t mExtra;
uint16_t mTimeout; uint16_t mTimeout;
char _fmtText[DISP_FMT_TEXT_LEN]; char mFmtText[DISP_FMT_TEXT_LEN];};
};

155
src/plugins/Display/Display_Mono_128X32.h

@ -0,0 +1,155 @@
//-----------------------------------------------------------------------------
// 2023 Ahoy, https://ahoydtu.de
// Creative Commons - https://creativecommons.org/licenses/by-nc-sa/4.0/deed
//-----------------------------------------------------------------------------
#pragma once
#include "Display_Mono.h"
class DisplayMono128X32 : public DisplayMono {
public:
DisplayMono128X32() : DisplayMono() {
mEnPowerSafe = true;
mEnScreenSaver = true;
mLuminance = 60;
mExtra = 0;
mDispY = 0;
mTimeout = DISP_DEFAULT_TIMEOUT; // interval at which to power save (milliseconds)
mUtcTs = NULL;
mType = 0;
}
void init(uint8_t type, uint8_t rotation, uint8_t cs, uint8_t dc, uint8_t reset, uint8_t clock, uint8_t data, uint32_t *utcTs, const char *version) {
if((0 == type) || (type > 4))
return;
u8g2_cb_t *rot = (u8g2_cb_t *)((rotation != 0x00) ? U8G2_R2 : U8G2_R0);
mType = type;
mDisplay = new U8G2_SSD1306_128X32_UNIVISION_F_HW_I2C(rot, reset, clock, data);
mUtcTs = utcTs;
mDisplay->begin();
calcLinePositions();
mDisplay->clearBuffer();
mDisplay->setContrast(mLuminance);
printText("AHOY!", 0);
printText("ahoydtu.de", 2);
printText(version, 3);
mDisplay->sendBuffer();
}
void config(bool enPowerSafe, bool enScreenSaver, uint8_t lum) {
mEnPowerSafe = enPowerSafe;
mEnScreenSaver = enScreenSaver;
mLuminance = lum;
}
void loop(void) {
if (mEnPowerSafe) {
if (mTimeout != 0)
mTimeout--;
}
}
void disp(float totalPower, float totalYieldDay, float totalYieldTotal, uint8_t isprod) {
mDisplay->clearBuffer();
// set Contrast of the Display to raise the lifetime
if (3 != mType)
mDisplay->setContrast(mLuminance);
if ((totalPower > 0) && (isprod > 0)) {
mTimeout = DISP_DEFAULT_TIMEOUT;
mDisplay->setPowerSave(false);
if (totalPower > 999)
snprintf(mFmtText, DISP_FMT_TEXT_LEN, "%2.2f kW", (totalPower / 1000));
else
snprintf(mFmtText, DISP_FMT_TEXT_LEN, "%3.0f W", totalPower);
printText(mFmtText, 0);
} else {
printText("offline", 0);
// check if it's time to enter power saving mode
if (mTimeout == 0)
mDisplay->setPowerSave(mEnPowerSafe);
}
snprintf(mFmtText, DISP_FMT_TEXT_LEN, "today: %4.0f Wh", totalYieldDay);
printText(mFmtText, 1);
snprintf(mFmtText, DISP_FMT_TEXT_LEN, "total: %.1f kWh", totalYieldTotal);
printText(mFmtText, 2);
IPAddress ip = WiFi.localIP();
if (!(mExtra % 10) && (ip))
printText(ip.toString().c_str(), 3);
else if (!(mExtra % 5)) {
snprintf(mFmtText, DISP_FMT_TEXT_LEN, "%d Inverter on", isprod);
printText(mFmtText, 3);
} else if (NULL != mUtcTs)
printText(ah::getTimeStr(gTimezone.toLocal(*mUtcTs)).c_str(), 3);
mDisplay->sendBuffer();
mDispY = 0;
mExtra++;
}
private:
void calcLinePositions() {
uint8_t yOff[] = {0, 0};
for (uint8_t i = 0; i < 4; i++) {
setFont(i);
yOff[getColumn(i)] += (mDisplay->getMaxCharHeight());
mLineYOffsets[i] = yOff[getColumn(i)];
if (isTwoRowLine(i))
yOff[getColumn(i)] += mDisplay->getMaxCharHeight();
yOff[getColumn(i)] += BOTTOM_MARGIN;
mLineXOffsets[i] = (getColumn(i) == 1 ? 80 : 0);
}
}
inline void setFont(uint8_t line) {
switch (line) {
case 0:
mDisplay->setFont(u8g2_font_9x15_tf);
break;
case 3:
mDisplay->setFont(u8g2_font_tom_thumb_4x6_tf);
break;
default:
mDisplay->setFont(u8g2_font_tom_thumb_4x6_tf);
break;
}
}
inline uint8_t getColumn(uint8_t line) {
if (line >= 1 && line <= 2)
return 1;
else
return 0;
}
inline bool isTwoRowLine(uint8_t line) {
return ((line >= 1) && (line <= 2));
}
void printText(const char *text, uint8_t line) {
setFont(line);
uint8_t dispX = mLineXOffsets[line] + ((mEnScreenSaver) ? (mExtra % 7) : 0);
if (isTwoRowLine(line)) {
String stringText = String(text);
int space = stringText.indexOf(" ");
mDisplay->drawStr(dispX, mLineYOffsets[line], stringText.substring(0, space).c_str());
if (space > 0)
mDisplay->drawStr(dispX, mLineYOffsets[line] + mDisplay->getMaxCharHeight(), stringText.substring(space + 1).c_str());
} else
mDisplay->drawStr(dispX, mLineYOffsets[line], text);
}
};

138
src/plugins/Display/Display_Mono_128X64.h

@ -0,0 +1,138 @@
//-----------------------------------------------------------------------------
// 2023 Ahoy, https://ahoydtu.de
// Creative Commons - https://creativecommons.org/licenses/by-nc-sa/4.0/deed
//-----------------------------------------------------------------------------
#pragma once
#include "Display_Mono.h"
class DisplayMono128X64 : public DisplayMono {
public:
DisplayMono128X64() : DisplayMono() {
mEnPowerSafe = true;
mEnScreenSaver = true;
mLuminance = 60;
mDispY = 0;
mTimeout = DISP_DEFAULT_TIMEOUT; // interval at which to power save (milliseconds)
mUtcTs = NULL;
mType = 0;
}
void init(uint8_t type, uint8_t rotation, uint8_t cs, uint8_t dc, uint8_t reset, uint8_t clock, uint8_t data, uint32_t *utcTs, const char *version) {
if((0 == type) || (type > 4))
return;
u8g2_cb_t *rot = (u8g2_cb_t *)((rotation != 0x00) ? U8G2_R2 : U8G2_R0);
mType = type;
switch (type) {
case 1:
mDisplay = new U8G2_SSD1306_128X64_NONAME_F_HW_I2C(rot, reset, clock, data);
break;
default:
case 2:
mDisplay = new U8G2_SH1106_128X64_NONAME_F_HW_I2C(rot, reset, clock, data);
break;
}
mUtcTs = utcTs;
mDisplay->begin();
calcLinePositions();
mDisplay->clearBuffer();
mDisplay->setContrast(mLuminance);
printText("AHOY!", 0, 35);
printText("ahoydtu.de", 2, 20);
printText(version, 3, 46);
mDisplay->sendBuffer();
}
void config(bool enPowerSafe, bool enScreenSaver, uint8_t lum) {
mEnPowerSafe = enPowerSafe;
mEnScreenSaver = enScreenSaver;
mLuminance = lum;
}
void loop(void) {
if (mEnPowerSafe) {
if (mTimeout != 0)
mTimeout--;
}
}
void disp(float totalPower, float totalYieldDay, float totalYieldTotal, uint8_t isprod) {
mDisplay->clearBuffer();
// set Contrast of the Display to raise the lifetime
if (3 != mType)
mDisplay->setContrast(mLuminance);
if ((totalPower > 0) && (isprod > 0)) {
mTimeout = DISP_DEFAULT_TIMEOUT;
mDisplay->setPowerSave(false);
if (totalPower > 999)
snprintf(mFmtText, DISP_FMT_TEXT_LEN, "%2.2f kW", (totalPower / 1000));
else
snprintf(mFmtText, DISP_FMT_TEXT_LEN, "%3.0f W", totalPower);
printText(mFmtText, 0);
} else {
printText("offline", 0, 25);
// check if it's time to enter power saving mode
if (mTimeout == 0)
mDisplay->setPowerSave(mEnPowerSafe);
}
snprintf(mFmtText, DISP_FMT_TEXT_LEN, "today: %4.0f Wh", totalYieldDay);
printText(mFmtText, 1);
snprintf(mFmtText, DISP_FMT_TEXT_LEN, "total: %.1f kWh", totalYieldTotal);
printText(mFmtText, 2);
IPAddress ip = WiFi.localIP();
if (!(mExtra % 10) && (ip))
printText(ip.toString().c_str(), 3);
else if (!(mExtra % 5)) {
snprintf(mFmtText, DISP_FMT_TEXT_LEN, "%d Inverter on", isprod);
printText(mFmtText, 3);
} else if (NULL != mUtcTs)
printText(ah::getDateTimeStr(gTimezone.toLocal(*mUtcTs)).c_str(), 3);
mDisplay->sendBuffer();
mDispY = 0;
mExtra++;
}
private:
void calcLinePositions() {
uint8_t yOff = 0;
for (uint8_t i = 0; i < 4; i++) {
setFont(i);
yOff += (mDisplay->getMaxCharHeight());
mLineYOffsets[i] = yOff;
}
}
inline void setFont(uint8_t line) {
switch (line) {
case 0:
mDisplay->setFont(u8g2_font_ncenB14_tr);
break;
case 3:
mDisplay->setFont(u8g2_font_5x8_tr);
break;
default:
mDisplay->setFont(u8g2_font_ncenB10_tr);
break;
}
}
void printText(const char *text, uint8_t line, uint8_t dispX = 5) {
setFont(line);
dispX += (mEnScreenSaver) ? (mExtra % 7) : 0;
mDisplay->drawStr(dispX, mLineYOffsets[line], text);
}
};

132
src/plugins/Display/Display_Mono_84X48.h

@ -0,0 +1,132 @@
//-----------------------------------------------------------------------------
// 2023 Ahoy, https://ahoydtu.de
// Creative Commons - https://creativecommons.org/licenses/by-nc-sa/4.0/deed
//-----------------------------------------------------------------------------
#pragma once
#include "Display_Mono.h"
class DisplayMono84X48 : public DisplayMono {
public:
DisplayMono84X48() : DisplayMono() {
mEnPowerSafe = true;
mEnScreenSaver = true;
mLuminance = 60;
mExtra = 0;
mDispY = 0;
mTimeout = DISP_DEFAULT_TIMEOUT; // interval at which to power save (milliseconds)
mUtcTs = NULL;
mType = 0;
}
void init(uint8_t type, uint8_t rotation, uint8_t cs, uint8_t dc, uint8_t reset, uint8_t clock, uint8_t data, uint32_t *utcTs, const char *version) {
if((0 == type) || (type > 4))
return;
u8g2_cb_t *rot = (u8g2_cb_t *)((rotation != 0x00) ? U8G2_R2 : U8G2_R0);
mType = type;
mDisplay = new U8G2_PCD8544_84X48_F_4W_SW_SPI(rot, clock, data, cs, dc, reset);
mUtcTs = utcTs;
mDisplay->begin();
calcLinePositions();
mDisplay->clearBuffer();
if (3 != mType)
mDisplay->setContrast(mLuminance);
printText("AHOY!", 0);
printText("ahoydtu.de", 2);
printText(version, 3);
mDisplay->sendBuffer();
}
void config(bool enPowerSafe, bool enScreenSaver, uint8_t lum) {
mEnPowerSafe = enPowerSafe;
mEnScreenSaver = enScreenSaver;
mLuminance = lum;
}
void loop(void) {
if (mEnPowerSafe) {
if (mTimeout != 0)
mTimeout--;
}
}
void disp(float totalPower, float totalYieldDay, float totalYieldTotal, uint8_t isprod) {
mDisplay->clearBuffer();
// set Contrast of the Display to raise the lifetime
if (3 != mType)
mDisplay->setContrast(mLuminance);
if ((totalPower > 0) && (isprod > 0)) {
mTimeout = DISP_DEFAULT_TIMEOUT;
mDisplay->setPowerSave(false);
if (totalPower > 999)
snprintf(mFmtText, DISP_FMT_TEXT_LEN, "%2.2f kW", (totalPower / 1000));
else
snprintf(mFmtText, DISP_FMT_TEXT_LEN, "%3.0f W", totalPower);
printText(mFmtText, 0);
} else {
printText("offline", 0);
// check if it's time to enter power saving mode
if (mTimeout == 0)
mDisplay->setPowerSave(mEnPowerSafe);
}
snprintf(mFmtText, DISP_FMT_TEXT_LEN, "today: %4.0f Wh", totalYieldDay);
printText(mFmtText, 1);
snprintf(mFmtText, DISP_FMT_TEXT_LEN, "total: %.1f kWh", totalYieldTotal);
printText(mFmtText, 2);
IPAddress ip = WiFi.localIP();
if (!(mExtra % 10) && (ip))
printText(ip.toString().c_str(), 3);
else if (!(mExtra % 5)) {
snprintf(mFmtText, DISP_FMT_TEXT_LEN, "%d Inverter on", isprod);
printText(mFmtText, 3);
} else if (NULL != mUtcTs)
printText(ah::getTimeStr(gTimezone.toLocal(*mUtcTs)).c_str(), 3);
mDisplay->sendBuffer();
mExtra = 1;
}
private:
void calcLinePositions() {
uint8_t yOff = 0;
for (uint8_t i = 0; i < 4; i++) {
setFont(i);
yOff += (mDisplay->getMaxCharHeight());
mLineYOffsets[i] = yOff;
}
}
inline void setFont(uint8_t line) {
switch (line) {
case 0:
mDisplay->setFont(u8g2_font_logisoso16_tr);
break;
case 3:
mDisplay->setFont(u8g2_font_5x8_tr);
break;
default:
mDisplay->setFont(u8g2_font_5x8_tr);
break;
}
}
void printText(const char *text, uint8_t line) {
uint8_t dispX = (line == 0) ? 10 : 5;
setFont(line);
dispX += (mEnScreenSaver) ? (mExtra % 7) : 0;
mDisplay->drawStr(dispX, mLineYOffsets[line], text);
}
};

51
src/plugins/Display/Display_ePaper.cpp

@ -57,8 +57,9 @@ void DisplayEPaper::init(uint8_t type, uint8_t _CS, uint8_t _DC, uint8_t _RST, u
} }
} }
void DisplayEPaper::config(uint8_t rotation) { void DisplayEPaper::config(uint8_t rotation, bool enPowerSafe) {
mDisplayRotation = rotation; mDisplayRotation = rotation;
mEnPowerSafe = enPowerSafe;
} }
//*************************************************************************** //***************************************************************************
@ -120,7 +121,29 @@ void DisplayEPaper::lastUpdatePaged() {
} while (_display->nextPage()); } while (_display->nextPage());
} }
//*************************************************************************** //***************************************************************************
void DisplayEPaper::actualPowerPaged(float _totalPower, float _totalYieldDay, float _totalYieldTotal, uint8_t _isprod) { void DisplayEPaper::offlineFooter() {
int16_t tbx, tby;
uint16_t tbw, tbh;
_display->setFont(&FreeSans9pt7b);
_display->setTextColor(GxEPD_WHITE);
_display->setPartialWindow(0, _display->height() - mHeadFootPadding, _display->width(), mHeadFootPadding);
_display->fillScreen(GxEPD_BLACK);
do {
if (NULL != mUtcTs) {
snprintf(_fmtText, sizeof(_fmtText), "offline");
_display->getTextBounds(_fmtText, 0, 0, &tbx, &tby, &tbw, &tbh);
uint16_t x = ((_display->width() - tbw) / 2) - tbx;
_display->setCursor(x, (_display->height() - 3));
_display->println(_fmtText);
}
} while (_display->nextPage());
}
//***************************************************************************
void DisplayEPaper::actualPowerPaged(float totalPower, float totalYieldDay, float totalYieldTotal, uint8_t isprod) {
int16_t tbx, tby; int16_t tbx, tby;
uint16_t tbw, tbh, x, y; uint16_t tbw, tbh, x, y;
@ -130,15 +153,19 @@ void DisplayEPaper::actualPowerPaged(float _totalPower, float _totalYieldDay, fl
_display->setPartialWindow(0, mHeadFootPadding, _display->width(), _display->height() - (mHeadFootPadding * 2)); _display->setPartialWindow(0, mHeadFootPadding, _display->width(), _display->height() - (mHeadFootPadding * 2));
_display->fillScreen(GxEPD_WHITE); _display->fillScreen(GxEPD_WHITE);
do { do {
if (_totalPower > 9999) { if (totalPower > 9999) {
snprintf(_fmtText, sizeof(_fmtText), "%.1f kW", (_totalPower / 10000)); snprintf(_fmtText, sizeof(_fmtText), "%.1f kW", (totalPower / 10000));
_changed = true; _changed = true;
} else if ((_totalPower > 0) && (_totalPower <= 9999)) { } else if ((totalPower > 0) && (totalPower <= 9999)) {
snprintf(_fmtText, sizeof(_fmtText), "%.0f W", _totalPower); snprintf(_fmtText, sizeof(_fmtText), "%.0f W", totalPower);
_changed = true; _changed = true;
} else { } else {
snprintf(_fmtText, sizeof(_fmtText), "offline"); snprintf(_fmtText, sizeof(_fmtText), "offline");
} }
if (totalPower == 0){
_display->fillRect(0, mHeadFootPadding, 200,200, GxEPD_BLACK);
_display->drawBitmap(0, 0, logo, 200, 200, GxEPD_WHITE);
} else {
_display->getTextBounds(_fmtText, 0, 0, &tbx, &tby, &tbw, &tbh); _display->getTextBounds(_fmtText, 0, 0, &tbx, &tby, &tbw, &tbh);
x = ((_display->width() - tbw) / 2) - tbx; x = ((_display->width() - tbw) / 2) - tbx;
_display->setCursor(x, mHeadFootPadding + tbh + 10); _display->setCursor(x, mHeadFootPadding + tbh + 10);
@ -148,7 +175,7 @@ void DisplayEPaper::actualPowerPaged(float _totalPower, float _totalYieldDay, fl
y = _display->height() / 2; y = _display->height() / 2;
_display->setCursor(5, y); _display->setCursor(5, y);
_display->print("today:"); _display->print("today:");
snprintf(_fmtText, _display->width(), "%.0f", _totalYieldDay); snprintf(_fmtText, _display->width(), "%.0f", totalYieldDay);
_display->getTextBounds(_fmtText, 0, 0, &tbx, &tby, &tbw, &tbh); _display->getTextBounds(_fmtText, 0, 0, &tbx, &tby, &tbw, &tbh);
x = ((_display->width() - tbw) / 2) - tbx; x = ((_display->width() - tbw) / 2) - tbx;
_display->setCursor(x, y); _display->setCursor(x, y);
@ -159,7 +186,7 @@ void DisplayEPaper::actualPowerPaged(float _totalPower, float _totalYieldDay, fl
y = y + tbh + 7; y = y + tbh + 7;
_display->setCursor(5, y); _display->setCursor(5, y);
_display->print("total:"); _display->print("total:");
snprintf(_fmtText, _display->width(), "%.1f", _totalYieldTotal); snprintf(_fmtText, _display->width(), "%.1f", totalYieldTotal);
_display->getTextBounds(_fmtText, 0, 0, &tbx, &tby, &tbw, &tbh); _display->getTextBounds(_fmtText, 0, 0, &tbx, &tby, &tbw, &tbh);
x = ((_display->width() - tbw) / 2) - tbx; x = ((_display->width() - tbw) / 2) - tbx;
_display->setCursor(x, y); _display->setCursor(x, y);
@ -168,9 +195,10 @@ void DisplayEPaper::actualPowerPaged(float _totalPower, float _totalYieldDay, fl
_display->println("kWh"); _display->println("kWh");
_display->setCursor(10, _display->height() - (mHeadFootPadding + 10)); _display->setCursor(10, _display->height() - (mHeadFootPadding + 10));
snprintf(_fmtText, sizeof(_fmtText), "%d Inverter online", _isprod); snprintf(_fmtText, sizeof(_fmtText), "%d Inverter online", isprod);
_display->println(_fmtText); _display->println(_fmtText);
}
} while (_display->nextPage()); } while (_display->nextPage());
} }
//*************************************************************************** //***************************************************************************
@ -185,11 +213,12 @@ void DisplayEPaper::loop(float totalPower, float totalYieldDay, float totalYield
// call the PowerPage to change the PV Power Values // call the PowerPage to change the PV Power Values
actualPowerPaged(totalPower, totalYieldDay, totalYieldTotal, isprod); actualPowerPaged(totalPower, totalYieldDay, totalYieldTotal, isprod);
// if there was an change and the Inverter is producing set a new Timestam in the footline // if there was an change and the Inverter is producing set a new Timestamp in the footline
if ((isprod > 0) && (_changed)) { if ((isprod > 0) && (_changed)) {
_changed = false; _changed = false;
lastUpdatePaged(); lastUpdatePaged();
} } else if((0 == totalPower) && (mEnPowerSafe))
offlineFooter();
_display->powerOff(); _display->powerOff();
} }

4
src/plugins/Display/Display_ePaper.h

@ -31,7 +31,7 @@ class DisplayEPaper {
DisplayEPaper(); DisplayEPaper();
void fullRefresh(); void fullRefresh();
void init(uint8_t type, uint8_t _CS, uint8_t _DC, uint8_t _RST, uint8_t _BUSY, uint8_t _SCK, uint8_t _MOSI, uint32_t *utcTs, const char* version); void init(uint8_t type, uint8_t _CS, uint8_t _DC, uint8_t _RST, uint8_t _BUSY, uint8_t _SCK, uint8_t _MOSI, uint32_t *utcTs, const char* version);
void config(uint8_t rotation); void config(uint8_t rotation, bool enPowerSafe);
void loop(float totalPower, float totalYieldDay, float totalYieldTotal, uint8_t isprod); void loop(float totalPower, float totalYieldDay, float totalYieldTotal, uint8_t isprod);
@ -39,6 +39,7 @@ class DisplayEPaper {
void headlineIP(); void headlineIP();
void actualPowerPaged(float _totalPower, float _totalYieldDay, float _totalYieldTotal, uint8_t _isprod); void actualPowerPaged(float _totalPower, float _totalYieldDay, float _totalYieldTotal, uint8_t _isprod);
void lastUpdatePaged(); void lastUpdatePaged();
void offlineFooter();
uint8_t mDisplayRotation; uint8_t mDisplayRotation;
bool _changed = false; bool _changed = false;
@ -47,6 +48,7 @@ class DisplayEPaper {
uint8_t mHeadFootPadding; uint8_t mHeadFootPadding;
GxEPD2_GFX* _display; GxEPD2_GFX* _display;
uint32_t *mUtcTs; uint32_t *mUtcTs;
bool mEnPowerSafe;
}; };
#endif // ESP32 #endif // ESP32

2
src/publisher/pubMqtt.h

@ -564,7 +564,7 @@ class PubMqtt {
void sendIvData() { void sendIvData() {
bool anyAvail = processIvStatus(); bool anyAvail = processIvStatus();
if (mLastAnyAvail != anyAvail) if (mLastAnyAvail != anyAvail)
mSendList.push(RealTimeRunData_Debug); // makes shure that total values are calculated mSendList.push(RealTimeRunData_Debug); // makes sure that total values are calculated
if(mSendList.empty()) if(mSendList.empty())
return; return;

2
src/web/RestApi.h

@ -563,7 +563,7 @@ class RestApi {
if(F("power") == jsonIn[F("cmd")]) if(F("power") == jsonIn[F("cmd")])
accepted = iv->setDevControlRequest((jsonIn[F("val")] == 1) ? TurnOn : TurnOff); accepted = iv->setDevControlRequest((jsonIn[F("val")] == 1) ? TurnOn : TurnOff);
else if(F("restart") == jsonIn[F("restart")]) else if(F("restart") == jsonIn[F("cmd")])
accepted = iv->setDevControlRequest(Restart); accepted = iv->setDevControlRequest(Restart);
else if(0 == strncmp("limit_", jsonIn[F("cmd")].as<const char*>(), 6)) { else if(0 == strncmp("limit_", jsonIn[F("cmd")].as<const char*>(), 6)) {
iv->powerLimit[0] = jsonIn["val"]; iv->powerLimit[0] = jsonIn["val"];

4
src/web/html/setup.html

@ -733,7 +733,7 @@
); );
} }
var opts = [[0, "None"], [1, "SSD1306 0.96\""], [2, "SH1106 1.3\""], [3, "Nokia5110"]]; var opts = [[0, "None"], [1, "SSD1306 0.96\" 128X64"], [2, "SH1106 1.3\""], [3, "Nokia5110"], [4, "SSD1306 0.96\" 128X32"]];
if("ESP32" == type) if("ESP32" == type)
opts.push([10, "ePaper"]); opts.push([10, "ePaper"]);
var dispType = sel("disp_typ", opts, obj["disp_typ"]); var dispType = sel("disp_typ", opts, obj["disp_typ"]);
@ -769,7 +769,7 @@
if(0 == dispType) if(0 == dispType)
cl.add("hide"); cl.add("hide");
else if(dispType <= 2) { // OLED else if(dispType <= 2 || dispType == 4) { // OLED
if(i < 2) if(i < 2)
cl.remove("hide"); cl.remove("hide");
else else

176
src/web/web.h

@ -639,14 +639,22 @@ class Web {
#ifdef ENABLE_PROMETHEUS_EP #ifdef ENABLE_PROMETHEUS_EP
// Note
// Prometheus exposition format is defined here: https://github.com/prometheus/docs/blob/main/content/docs/instrumenting/exposition_formats.md
// TODO: Check packetsize for MAX_NUM_INVERTERS. Successfull Tested with 4 Inverters (each with 4 channels)
enum { enum {
metricsStateStart, metricsStateInverter, metricStateRealtimeData,metricsStateAlarmData,metricsStateEnd metricsStateStart,
metricsStateInverter1, metricsStateInverter2, metricsStateInverter3, metricsStateInverter4,
metricStateRealtimeFieldId, metricStateRealtimeInverterId,
metricsStateAlarmData,
metricsStateEnd
} metricsStep; } metricsStep;
int metricsInverterId,metricsChannelId; int metricsInverterId;
uint8_t metricsFieldId;
bool metricDeclared;
void showMetrics(AsyncWebServerRequest *request) { void showMetrics(AsyncWebServerRequest *request) {
DPRINTLN(DBG_VERBOSE, F("web::showMetrics")); DPRINTLN(DBG_VERBOSE, F("web::showMetrics"));
metricsStep = metricsStateStart; metricsStep = metricsStateStart;
AsyncWebServerResponse *response = request->beginChunkedResponse(F("text/plain"), AsyncWebServerResponse *response = request->beginChunkedResponse(F("text/plain"),
[this](uint8_t *buffer, size_t maxLen, size_t filledLength) -> size_t [this](uint8_t *buffer, size_t maxLen, size_t filledLength) -> size_t
@ -659,7 +667,11 @@ class Web {
char type[60], topic[100], val[25]; char type[60], topic[100], val[25];
size_t len = 0; size_t len = 0;
int alarmChannelId; int alarmChannelId;
int metricsChannelId;
// Perform grouping on metrics according to format specification
// Each step must return at least one character. Otherwise the processing of AsyncWebServerResponse stops.
// So several "Info:" blocks are used to keep the transmission going
switch (metricsStep) { switch (metricsStep) {
case metricsStateStart: // System Info & NRF Statistics : fit to one packet case metricsStateStart: // System Info & NRF Statistics : fit to one packet
snprintf(type,sizeof(type),"# TYPE ahoy_solar_info gauge\n"); snprintf(type,sizeof(type),"# TYPE ahoy_solar_info gauge\n");
@ -688,93 +700,138 @@ class Web {
metrics += radioStatistic(F("tx_cnt"), mSys->Radio.mSendCnt); metrics += radioStatistic(F("tx_cnt"), mSys->Radio.mSendCnt);
len = snprintf((char *)buffer,maxLen,"%s",metrics.c_str()); len = snprintf((char *)buffer,maxLen,"%s",metrics.c_str());
// Start Inverter loop // Next is Inverter information
metricsInverterId = 0; metricsInverterId = 0;
metricsStep = metricsStateInverter; metricsStep = metricsStateInverter1;
break; break;
case metricsStateInverter: // Inverter loop case metricsStateInverter1: // Information about all inverters configured : fit to one packet
if (metricsInverterId < mSys->getNumInverters()) { metrics = "# TYPE ahoy_solar_inverter_info gauge\n";
iv = mSys->getInverterByPos(metricsInverterId); metrics += inverterMetric(topic, sizeof(topic),"ahoy_solar_inverter_info{name=\"%s\",serial=\"%12llx\"} 1\n",
if(NULL != iv) { [](Inverter<> *iv,IApp *mApp)-> uint64_t {return iv->config->serial.u64;});
// Inverter info : fit to one packet len = snprintf((char *)buffer,maxLen,"%s",metrics.c_str());
snprintf(type,sizeof(type),"# TYPE ahoy_solar_inverter_info gauge\n"); metricsStep = metricsStateInverter2;
snprintf(topic,sizeof(topic),"ahoy_solar_inverter_info{name=\"%s\",serial=\"%12llx\"} 1\n", break;
iv->config->name, iv->config->serial.u64);
metrics = String(type) + String(topic);
snprintf(type,sizeof(type),"# TYPE ahoy_solar_inverter_is_enabled gauge\n"); case metricsStateInverter2: // Information about all inverters configured : fit to one packet
snprintf(topic,sizeof(topic),"ahoy_solar_inverter_is_enabled {inverter=\"%s\"} %d\n",iv->config->name,iv->config->enabled); metrics += "# TYPE ahoy_solar_inverter_is_enabled gauge\n";
metrics += String(type) + String(topic); metrics += inverterMetric(topic, sizeof(topic),"ahoy_solar_inverter_is_enabled {inverter=\"%s\"} %d\n",
[](Inverter<> *iv,IApp *mApp)-> uint64_t {return iv->config->enabled;});
snprintf(type,sizeof(type),"# TYPE ahoy_solar_inverter_is_available gauge\n"); len = snprintf((char *)buffer,maxLen,"%s",metrics.c_str());
snprintf(topic,sizeof(topic),"ahoy_solar_inverter_is_available {inverter=\"%s\"} %d\n",iv->config->name,iv->isAvailable(mApp->getTimestamp())); metricsStep = metricsStateInverter3;
metrics += String(type) + String(topic); break;
snprintf(type,sizeof(type),"# TYPE ahoy_solar_inverter_is_producing gauge\n"); case metricsStateInverter3: // Information about all inverters configured : fit to one packet
snprintf(topic,sizeof(topic),"ahoy_solar_inverter_is_producing {inverter=\"%s\"} %d\n",iv->config->name,iv->isProducing(mApp->getTimestamp())); metrics += "# TYPE ahoy_solar_inverter_is_available gauge\n";
metrics += String(type) + String(topic); metrics += inverterMetric(topic, sizeof(topic),"ahoy_solar_inverter_is_available {inverter=\"%s\"} %d\n",
[](Inverter<> *iv,IApp *mApp)-> uint64_t {return iv->isAvailable(mApp->getTimestamp());});
len = snprintf((char *)buffer,maxLen,"%s",metrics.c_str());
metricsStep = metricsStateInverter4;
break;
case metricsStateInverter4: // Information about all inverters configured : fit to one packet
metrics += "# TYPE ahoy_solar_inverter_is_producing gauge\n";
metrics += inverterMetric(topic, sizeof(topic),"ahoy_solar_inverter_is_producing {inverter=\"%s\"} %d\n",
[](Inverter<> *iv,IApp *mApp)-> uint64_t {return iv->isProducing(mApp->getTimestamp());});
len = snprintf((char *)buffer,maxLen,"%s",metrics.c_str()); len = snprintf((char *)buffer,maxLen,"%s",metrics.c_str());
// Start Realtime Field loop
metricsFieldId = FLD_UDC;
metricsStep = metricStateRealtimeFieldId;
break;
// Start Realtime Data Channel loop for this inverter case metricStateRealtimeFieldId: // Iterate over all defined fields
metricsChannelId = 0; if (metricsFieldId < FLD_LAST_ALARM_CODE) {
metricsStep = metricStateRealtimeData; metrics = "# Info: processing realtime field #"+String(metricsFieldId)+"\n";
} metricDeclared = false;
metricsInverterId = 0;
metricsStep = metricStateRealtimeInverterId;
} else { } else {
metricsStep = metricsStateEnd; metrics = "# Info: all realtime fields processed\n";
metricsStep = metricsStateAlarmData;
} }
len = snprintf((char *)buffer,maxLen,"%s",metrics.c_str());
break; break;
case metricStateRealtimeData: // Realtime Data Channel loop case metricStateRealtimeInverterId: // Iterate over all inverters for this field
metrics = "";
if (metricsInverterId < mSys->getNumInverters()) {
// process all channels of this inverter
iv = mSys->getInverterByPos(metricsInverterId); iv = mSys->getInverterByPos(metricsInverterId);
if (NULL != iv) {
rec = iv->getRecordStruct(RealTimeRunData_Debug); rec = iv->getRecordStruct(RealTimeRunData_Debug);
if (metricsChannelId < rec->length) { for (metricsChannelId=0; metricsChannelId < rec->length;metricsChannelId++) {
uint8_t channel = rec->assign[metricsChannelId].ch; uint8_t channel = rec->assign[metricsChannelId].ch;
// Skip entry if maxPwr is 0 and it's not the inverter channel (channel 0)
// Try inverter channel (channel 0) or any channel with maxPwr > 0
if (0 == channel || 0 != iv->config->chMaxPwr[channel-1]) { if (0 == channel || 0 != iv->config->chMaxPwr[channel-1]) {
if (metricsFieldId == iv->getByteAssign(metricsChannelId, rec)->fieldId) {
// This is the correct field to report
std::tie(promUnit, promType) = convertToPromUnits(iv->getUnit(metricsChannelId, rec)); std::tie(promUnit, promType) = convertToPromUnits(iv->getUnit(metricsChannelId, rec));
snprintf(type, sizeof(type), "# TYPE ahoy_solar_%s%s %s", iv->getFieldName(metricsChannelId, rec), promUnit.c_str(), promType.c_str()); // Declare metric only once
if (!metricDeclared) {
snprintf(type, sizeof(type), "# TYPE ahoy_solar_%s%s %s\n", iv->getFieldName(metricsChannelId, rec), promUnit.c_str(), promType.c_str());
metrics += type;
metricDeclared = true;
}
// report value
if (0 == channel) { if (0 == channel) {
snprintf(topic, sizeof(topic), "ahoy_solar_%s%s{inverter=\"%s\"}", iv->getFieldName(metricsChannelId, rec), promUnit.c_str(), iv->config->name); snprintf(topic, sizeof(topic), "ahoy_solar_%s%s{inverter=\"%s\"}", iv->getFieldName(metricsChannelId, rec), promUnit.c_str(), iv->config->name);
} else { } else {
snprintf(topic, sizeof(topic), "ahoy_solar_%s%s{inverter=\"%s\",channel=\"%s\"}", iv->getFieldName(metricsChannelId, rec), promUnit.c_str(), iv->config->name,iv->config->chName[channel-1]); snprintf(topic, sizeof(topic), "ahoy_solar_%s%s{inverter=\"%s\",channel=\"%s\"}", iv->getFieldName(metricsChannelId, rec), promUnit.c_str(), iv->config->name,iv->config->chName[channel-1]);
} }
snprintf(val, sizeof(val), "%.3f", iv->getValue(metricsChannelId, rec)); snprintf(val, sizeof(val), " %.3f\n", iv->getValue(metricsChannelId, rec));
len = snprintf((char*)buffer,maxLen,"%s\n%s %s\n",type,topic,val); metrics += topic;
metrics += val;
}
}
}
if (metrics.length() < 1) {
metrics = "# Info: Field #"+String(metricsFieldId)+" not available for inverter #"+String(metricsInverterId)+". Skipping remaining inverters\n";
metricsFieldId++; // Process next field Id
metricsStep = metricStateRealtimeFieldId;
}
} else { } else {
len = snprintf((char*)buffer,maxLen,"#\n"); // At least one char to send otherwise the transmission ends. metrics = "# Info: No data for field #"+String(metricsFieldId)+" of inverter #"+String(metricsInverterId)+". Skipping remaining inverters\n";
metricsFieldId++; // Process next field Id
metricsStep = metricStateRealtimeFieldId;
} }
// Stay in this state and try next inverter
metricsChannelId++; metricsInverterId++;
} else { } else {
len = snprintf((char*)buffer,maxLen,"#\n"); // At least one char to send otherwise the transmission ends. metrics = "# Info: All inverters for field #"+String(metricsFieldId)+" processed.\n";
metricsFieldId++; // Process next field Id
// All realtime data channels processed --> try alarm data metricsStep = metricStateRealtimeFieldId;
metricsStep = metricsStateAlarmData;
} }
len = snprintf((char *)buffer,maxLen,"%s",metrics.c_str());
break; break;
case metricsStateAlarmData: // Alarm Info loop case metricsStateAlarmData: // Alarm Info loop : fit to one packet
// Perform grouping on metrics according to Prometheus exposition format specification
snprintf(type, sizeof(type),"# TYPE ahoy_solar_%s gauge\n",fields[FLD_LAST_ALARM_CODE]);
metrics = type;
for (metricsInverterId = 0; metricsInverterId < mSys->getNumInverters();metricsInverterId++) {
iv = mSys->getInverterByPos(metricsInverterId); iv = mSys->getInverterByPos(metricsInverterId);
if (NULL != iv) {
rec = iv->getRecordStruct(AlarmData); rec = iv->getRecordStruct(AlarmData);
// simple hack : there is only one channel with alarm data // simple hack : there is only one channel with alarm data
// TODO: find the right one channel with the alarm id // TODO: find the right one channel with the alarm id
alarmChannelId = 0; alarmChannelId = 0;
// printf("AlarmData Length %d\n",rec->length);
if (alarmChannelId < rec->length) { if (alarmChannelId < rec->length) {
//uint8_t channel = rec->assign[alarmChannelId].ch;
std::tie(promUnit, promType) = convertToPromUnits(iv->getUnit(alarmChannelId, rec)); std::tie(promUnit, promType) = convertToPromUnits(iv->getUnit(alarmChannelId, rec));
snprintf(type, sizeof(type), "# TYPE ahoy_solar_%s%s %s", iv->getFieldName(alarmChannelId, rec), promUnit.c_str(), promType.c_str());
snprintf(topic, sizeof(topic), "ahoy_solar_%s%s{inverter=\"%s\"}", iv->getFieldName(alarmChannelId, rec), promUnit.c_str(), iv->config->name); snprintf(topic, sizeof(topic), "ahoy_solar_%s%s{inverter=\"%s\"}", iv->getFieldName(alarmChannelId, rec), promUnit.c_str(), iv->config->name);
snprintf(val, sizeof(val), "%.3f", iv->getValue(alarmChannelId, rec)); snprintf(val, sizeof(val), " %.3f\n", iv->getValue(alarmChannelId, rec));
len = snprintf((char*)buffer,maxLen,"%s\n%s %s\n",type,topic,val); metrics += topic;
} else { metrics += val;
len = snprintf((char*)buffer,maxLen,"#\n"); // At least one char to send otherwise the transmission ends.
} }
// alarm channel processed --> try next inverter }
metricsInverterId++; }
metricsStep = metricsStateInverter; len = snprintf((char*)buffer,maxLen,"%s",metrics.c_str());
metricsStep = metricsStateEnd;
break; break;
case metricsStateEnd: case metricsStateEnd:
@ -787,6 +844,21 @@ class Web {
request->send(response); request->send(response);
} }
// Traverse all inverters and collect the metric via valueFunc
String inverterMetric(char *buffer, size_t len, const char *format, std::function<uint64_t(Inverter<> *iv, IApp *mApp)> valueFunc) {
Inverter<> *iv;
String metric = "";
for (int metricsInverterId = 0; metricsInverterId < mSys->getNumInverters();metricsInverterId++) {
iv = mSys->getInverterByPos(metricsInverterId);
if (NULL != iv) {
snprintf(buffer,len,format,iv->config->name, valueFunc(iv,mApp));
metric += String(buffer);
}
}
return metric;
}
String radioStatistic(String statistic, uint32_t value) { String radioStatistic(String statistic, uint32_t value) {
char type[60], topic[80], val[25]; char type[60], topic[80], val[25];
snprintf(type, sizeof(type), "# TYPE ahoy_solar_radio_%s counter",statistic.c_str()); snprintf(type, sizeof(type), "# TYPE ahoy_solar_radio_%s counter",statistic.c_str());

3
tools/rpi/ahoy.yml.example

@ -2,11 +2,14 @@
ahoy: ahoy:
interval: 5 interval: 5
transmit_retries: 5
logging: logging:
filename: 'hoymiles.log' filename: 'hoymiles.log'
# DEBUG, INFO, WARNING, ERROR, FATAL # DEBUG, INFO, WARNING, ERROR, FATAL
level: 'INFO' level: 'INFO'
max_log_filesize: 1000000
max_log_files: 1
sunset: sunset:
disabled: false disabled: false

29
tools/rpi/hoymiles/__init__.py

@ -297,8 +297,8 @@ class InverterPacketFragment:
class HoymilesNRF: class HoymilesNRF:
"""Hoymiles NRF24 Interface""" """Hoymiles NRF24 Interface"""
tx_channel_id = 0 tx_channel_id = 2
tx_channel_list = [40] tx_channel_list = [3,23,40,61,75]
rx_channel_id = 0 rx_channel_id = 0
rx_channel_list = [3,23,40,61,75] rx_channel_list = [3,23,40,61,75]
rx_channel_ack = False rx_channel_ack = False
@ -332,6 +332,12 @@ class HoymilesNRF:
:rtype: bool :rtype: bool
""" """
self.next_tx_channel()
if HOYMILES_TRANSACTION_LOGGING:
c_datetime = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
logging.debug(f'{c_datetime} Transmit {len(packet)} bytes channel {self.tx_channel}: {hexify_payload(packet)}')
if not txpower: if not txpower:
txpower = self.txpower txpower = self.txpower
@ -363,13 +369,13 @@ class HoymilesNRF:
""" """
Receive Packets Receive Packets
:param timeout: receive timeout in nanoseconds (default: 12e8) :param timeout: receive timeout in nanoseconds (default: 5e8)
:type timeout: int :type timeout: int
:yields: fragment :yields: fragment
""" """
if not timeout: if not timeout:
timeout=12e8 timeout=5e8
self.radio.setChannel(self.rx_channel) self.radio.setChannel(self.rx_channel)
self.radio.setAutoAck(False) self.radio.setAutoAck(False)
@ -415,7 +421,7 @@ class HoymilesNRF:
self.radio.setChannel(self.rx_channel) self.radio.setChannel(self.rx_channel)
self.radio.startListening() self.radio.startListening()
time.sleep(0.005) time.sleep(0.004)
def next_rx_channel(self): def next_rx_channel(self):
""" """
@ -433,6 +439,15 @@ class HoymilesNRF:
return True return True
return False return False
def next_tx_channel(self):
"""
Select next channel from hop list
"""
self.tx_channel_id = self.tx_channel_id + 1
if self.tx_channel_id >= len(self.tx_channel_list):
self.tx_channel_id = 0
@property @property
def tx_channel(self): def tx_channel(self):
""" """
@ -612,10 +627,6 @@ class InverterTransaction:
packet = self.tx_queue.pop(0) packet = self.tx_queue.pop(0)
if HOYMILES_TRANSACTION_LOGGING:
c_datetime = datetime.now().strftime("%Y-%m-%d %H:%M:%S.%f")
logging.debug(f'{c_datetime} Transmit {len(packet)} | {hexify_payload(packet)}')
self.radio.transmit(packet, txpower=self.txpower) self.radio.transmit(packet, txpower=self.txpower)
wait = False wait = False

10
tools/rpi/hoymiles/__main__.py

@ -19,6 +19,7 @@ import yaml
from yaml.loader import SafeLoader from yaml.loader import SafeLoader
import hoymiles import hoymiles
import logging import logging
from logging.handlers import RotatingFileHandler
################################################################################ ################################################################################
""" Signal Handler """ """ Signal Handler """
@ -127,6 +128,7 @@ def main_loop(ahoy_config):
dtu_name = ahoy_config.get('dtu', {}).get('name', 'hoymiles-dtu') dtu_name = ahoy_config.get('dtu', {}).get('name', 'hoymiles-dtu')
sunset.sun_status2mqtt(dtu_ser, dtu_name) sunset.sun_status2mqtt(dtu_ser, dtu_name)
loop_interval = ahoy_config.get('interval', 1) loop_interval = ahoy_config.get('interval', 1)
transmit_retries = ahoy_config.get('transmit_retries', 5)
try: try:
do_init = True do_init = True
@ -143,7 +145,7 @@ def main_loop(ahoy_config):
sys.exit(999) sys.exit(999)
if hoymiles.HOYMILES_DEBUG_LOGGING: if hoymiles.HOYMILES_DEBUG_LOGGING:
logging.info(f'Poll inverter name={inverter["name"]} ser={inverter["serial"]}') logging.info(f'Poll inverter name={inverter["name"]} ser={inverter["serial"]}')
poll_inverter(inverter, dtu_ser, do_init, 3) poll_inverter(inverter, dtu_ser, do_init, transmit_retries)
do_init = False do_init = False
if loop_interval > 0: if loop_interval > 0:
@ -298,6 +300,8 @@ def init_logging(ahoy_config):
log_config = ahoy_config.get('logging') log_config = ahoy_config.get('logging')
fn = 'hoymiles.log' fn = 'hoymiles.log'
lvl = logging.ERROR lvl = logging.ERROR
max_log_filesize = 1000000
max_log_files = 1
if log_config: if log_config:
fn = log_config.get('filename', fn) fn = log_config.get('filename', fn)
level = log_config.get('level', 'ERROR') level = log_config.get('level', 'ERROR')
@ -311,9 +315,11 @@ def init_logging(ahoy_config):
lvl = logging.ERROR lvl = logging.ERROR
elif level == 'FATAL': elif level == 'FATAL':
lvl = logging.FATAL lvl = logging.FATAL
max_log_filesize = log_config.get('max_log_filesize', max_log_filesize)
max_log_files = log_config.get('max_log_files', max_log_files)
if hoymiles.HOYMILES_TRANSACTION_LOGGING: if hoymiles.HOYMILES_TRANSACTION_LOGGING:
lvl = logging.DEBUG lvl = logging.DEBUG
logging.basicConfig(filename=fn, format='%(asctime)s %(levelname)s: %(message)s', datefmt='%Y-%m-%d %H:%M:%S', level=lvl) logging.basicConfig(handlers=[RotatingFileHandler(fn, maxBytes=max_log_filesize, backupCount=max_log_files)], format='%(asctime)s %(levelname)s: %(message)s', datefmt='%Y-%m-%d %H:%M:%S', level=lvl)
dtu_name = ahoy_config.get('dtu',{}).get('name','hoymiles-dtu') dtu_name = ahoy_config.get('dtu',{}).get('name','hoymiles-dtu')
logging.info(f'start logging for {dtu_name} with level: {logging.root.level}') logging.info(f'start logging for {dtu_name} with level: {logging.root.level}')

8
tools/rpi/hoymiles/decoders/__init__.py

@ -515,9 +515,17 @@ class Hm300Decode0B(StatusResponse):
""" reactive power """ """ reactive power """
return self.unpack('>H', 20)[0]/10 return self.unpack('>H', 20)[0]/10
@property @property
def powerfactor(self):
""" Powerfactor """
return self.unpack('>H', 24)[0]/1000
@property
def temperature(self): def temperature(self):
""" Inverter temperature in °C """ """ Inverter temperature in °C """
return self.unpack('>h', 26)[0]/10 return self.unpack('>h', 26)[0]/10
@property
def event_count(self):
""" Event counter """
return self.unpack('>H', 28)[0]
class Hm300Decode0C(Hm300Decode0B): class Hm300Decode0C(Hm300Decode0B):
""" 1121-series mirco-inverters status data """ """ 1121-series mirco-inverters status data """

Loading…
Cancel
Save