Schlagwort-Archive: mqtt

LAN für Pylontech PV-Akkustatus, OpenDTU und mehr im HomeAssistant

Loading

In dem Beitrag Pylontech PV-Akkustatus im HomeAssistant hatte ich das Projekt „Pylontech-Battery-Monitoring“ der folgenden GitHub Links etwas aufgehübscht und eine Platine gezeichnet, um das ganze Konstrukt etwas kompakter und professioneller aufzubereiten. 
https://github.com/irekzielinski/Pylontech-Battery-Monitoring
https://github.com/hidaba/PylontechMonitoring
Im Homeassistant wurden damit, bzw. werden, sämtliche Batteriedaten der Pylontech Akku Module angezeigt. Super! Wenn ich aber einen Blick in die Geräteliste der, in meinen Wifi – Netzen angemeldeten Geräte ansehe, wird mir fast übel – es sind mittlerweile viel zu viele Funkgeräte, vor allem aus dem SmartHome Bereich, die sich die Kanalbandbreite teilen.  So ist es mein aktueller Plan, einige der Smarthome Geräte ins verkabelte LAN-Netzwerk zu bringen.

Die selber gebastelten Geräte auf Basis der ESPs bieten sich dafür an. Das sind dann Geräte, wie das OpenDTU Interface, das EVU-Smarthomeinterface oder wie hier, die Schnittstelle von der seriellen Console der Pylontechakkus zum MQTT Server im Homeassistant.

Dazu habe ich einige Versuche mit Boards wie dem OLIMEX ESP32-PoE und dem WT32-ETH01 gemacht. Die Olimexplatine hätte den großen Vorteil, auch über PoE mit Energie versorgt werden zu können. Allerdings ist die Spannungsversorgung bei PoE Betrieb so „schlecht“, dass die benötigten Standards externer Boards nicht erfüllt werden. Hier kann ich zum Beispiel das Funkmodul NRF24L01 erwähnen. Damit habe ich einige Versuche gemacht und beschlossen, die PoE Funktionalität vorerst einmal außer Acht zu lassen. So entstand dann der Plan mit dem WT32-ETH01 auf dem ein ESP32 arbeitet, ein universelles Board zu designen, auf dem mehrere Schnittstellen vorhanden sind. Es sollte folgendes können:

  • mit OpenDTU und NRF24L01 mit den PV-Invertern kommunizieren
  • über die Pylontech Console via MQTT mit dem Smarthome System kommunizieren
  • eine optionale CAN Schnittstelle besitzen
  • neben der RS232 Schnittstelle auch über RS422/RS485 kommunizieren zu können
  • die Spannungsversorgung über 5V USB zu erhalten
  • und alles schön klein und kompakt in einem Gehäuse verpackt zu haben

So habe ich dazu eine Schaltung entworfen und eine Platine gezeichnet. Bei einer Fernost PCB Manufaktur ließ ich die Boards fertigen. Das Bestücken ist auch schnell erledigt.

Schaltplan des Universal Lan Interface

Das nachstehende Bild zeigt das Platinenlayout vor der Fertigung.

Das WT32-ETH01 Board besitzt keinen USB-Port zum Programmieren des Controllers. Es wird über einen externen USB-UART Adapter programmiert. Um den Programmiermodus zu aktivieren, muss auch ein IO Pin gegen GND geschaltet werden. Um das etwas zu vereinfachen, gibt es jetzt auf dem Board einen Jumper „PROG“. Wenn diese Brücke gesteckt ist, kann der der WT32 die Firmwarefiles empfangen. Einen Pinheader Steckplatz „TO-FTDI“ habe ich als Anschlussmöglichkeit für den USB-UART Adapter vorgesehen.
Die Platine ist nun so konzipiert, dass man damit unterschiedliche Geräte bedienen kann. Schliesst man ein NRF24L01 Modul an den Pinheader „NRF24L01+“ an und flasht das ESP32-OpenDTU Image auf den Controller, dann können damit die Wechselrichterdaten empfangen und über das LAN Netzwerk übertragen werden. Ein geeignetes IO-config jason-file für die Benutzung des WT32 habe ich erstellt.

Eine weitere Anwendung ist die Verwendung der Platine mit der seriellen Ausgabe der Batteriedaten der Pylontech PV Akkus. Die Akkus stellen einen „Console“ Port zur Verfügung der eine RS232 Schnittstelle darstellt. Über diese werden die Daten zum WT32 Controller übertragen und stehen dann über LAN im lokalen Netzwerk zur Verfügung. 

Dazu habe ich das ESP8266 script von hidaba und irekzielinski für die ESP32 Controller angepasst. (siehe Code am Ende des Beitrags)

Ist der Code kompiliert und hochgeladen, dann sollte nach Anschluss aller Verbindungen unter der eingestellten IP-Adresse der Status der Pylontec Batterien zu sehen sein.

Ausführung der Platine mit Pylontech Setup
Ausführung mit OpenDTU und NRF24L01 Setup

Die im Bild dargestellten Gerätesetups sind mit einem Gehäuse ausgestattet. Die „.stl“ Dateien dazu habe ich mit FreeCad erstellt auf thingiverse veröffentlicht.

Link zu den thingiverse Dateien

Hier der angepasste Code für die Verwendung mit dem WT32-ETH01 Board:

Die Datei „TimerConfig.h“ ist zu erstellen. Folgender Inhalt muss in der Datei stehen:

// TimerConfig.h
#ifndef TIMERCONFIG_H
#define TIMERCONFIG_H
#define TIMER_BASE_CLK 80000000
#endif // TIMERCONFIG_H

Ist die Datei angelegt, sollte sie im Verzeichnis des Hauptprogramms liegen. Hier das Hauptprogramm:

 //Pylontec2MQTT interface  
 //code by https://github.com/irekzielinski/Pylontech-Battery-Monitoring  
 //  and https://github.com/hidaba/PylontechMonitoring  
 // the original code used WEMOS Boards with ESP8266  
 // code changed to use with WT32-ETH01 Board for use with LAN connection  
 // changes by ingmarsretro 01/2025  
 #include <ETH.h>  
 #include <WiFi.h>  
 #include <ESPmDNS.h>  
 #include <ArduinoOTA.h>  
 #include <WebServer.h>  
 #include <circular_log.h>  
 #include <ArduinoJson.h>  
 #include <NTPClient.h>  
 #include "TimerConfig.h"  
 #include <ESP32TimerInterrupt.h>  
 //+++ START CONFIGURATION +++  
 #define LED 12  
 #define RXD2 5  
 #define TXD2 17  
 #define HOSTNAME "mppsolar-pylontec"  
 #define STATIC_IP  
 IPAddress local_IP(192, 168, xx, yy);  
 IPAddress subnet(255, 255, 255, 0);  
 IPAddress gateway(192, 168, yy, zz);  
 IPAddress primaryDNS(192, 168, yy, ww);  
 //Uncomment for authentication page  
 //#define AUTHENTICATION  
 const char* www_username = "admin";  
 const char* www_password = "password";  
 //IMPORTANT: Uncomment this line if you want to enable MQTT (and fill correct MQTT_ values below):  
 #define ENABLE_MQTT  
 // Set offset time in seconds to adjust for your timezone, for example:  
 // GMT +1 = 3600  
 // GMT 0 = 0  
 #define GMT 3600  
 //NOTE 1: if you want to change what is pushed via MQTT - edit function: pushBatteryDataToMqtt.  
 //NOTE 2: MQTT_TOPIC_ROOT is where battery will push MQTT topics. For example "soc" will be pushed to: "home/grid_battery/soc"  
 #define MQTT_SERVER    "192.168.aa.bb"  
 #define MQTT_PORT     1883  
 #define MQTT_USER     ""  
 #define MQTT_PASSWORD   ""  
 #define MQTT_TOPIC_ROOT  "ingmarsretro/pylontec/" //this is where mqtt data will be pushed  
 #define MQTT_PUSH_FREQ_SEC 2 //maximum mqtt update frequency in seconds  
 //+++  END CONFIGURATION +++  
 #ifdef ENABLE_MQTT  
 #include <PubSubClient.h>  
 WiFiClient ethClient;  
 PubSubClient mqttClient(ethClient);  
 #endif //ENABLE_MQTT  
 //text response  
 char g_szRecvBuff[7000];  
 const long utcOffsetInSeconds = GMT;  
 char daysOfTheWeek[7][12] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"};  
 // Define NTP Client to get time  
 WiFiUDP ntpUDP;  
 NTPClient timeClient(ntpUDP, "pool.ntp.org", utcOffsetInSeconds);  
 //ESP8266WebServer server(80);  
 WebServer server(80);  
 circular_log<7000> g_log;  
 bool ntpTimeReceived = false;  
 int g_baudRate = 0;  
 void Log(const char* msg)  
 {  
  g_log.Log(msg);  
 }  
 //Define Interrupt Timer to Calculate Power meter every second (kWh)  
 #define USING_TIM_DIV1 true                       // for shortest and most accurate timer  
 //ESP8266Timer ITimer;  
 ESP32Timer ITimer(0); // Use timer 0  
 bool setInterval(unsigned long interval, timer_callback callback);   // interval (in microseconds)  
 #define TIMER_INTERVAL_MS 1000  
 //Global Variables for the Power Meter - accessible from the calculating interrupt und from main  
 unsigned long powerIN = 0;    //WS gone in to the BAttery  
 unsigned long powerOUT = 0;   //WS gone out of the Battery  
 //Global Variables for the Power Meter - Überlauf  
 unsigned long powerINWh = 0;    //WS gone in to the BAttery  
 unsigned long powerOUTWh = 0;   //WS gone out of the Battery  
 void setup() {  
  memset(g_szRecvBuff, 0, sizeof(g_szRecvBuff)); //clean variable  
  pinMode(LED, OUTPUT);   
  //digitalWrite(LED, HIGH);//high is off  
  digitalWrite(LED, LOW);//low is off  
  // put your setup code here, to run once:  
  //WiFi.mode(WIFI_STA);  
  //WiFi.persistent(false); //our credentialss are hardcoded, so we don't need ESP saving those each boot (will save on flash wear)  
  //WiFi.hostname(HOSTNAME);  
  ETH.begin();  
  ETH.setHostname(HOSTNAME);  
   #ifdef STATIC_IP  
    ETH.config(local_IP, gateway, subnet, primaryDNS);  
  #endif  
  for(int ix=0; ix<10; ix++)  
  {  
   Log("Wait for LAN Connection");  
   if (ETH.linkUp()) {  
     Serial2.println("Ethernet connected");  
     Serial2.print("IP Address: ");  
     Serial2.println(ETH.localIP());  
   } else {  
     Serial2.println("Failed to connect to Ethernet");  
   }  
   delay(1000);  
  }  
  ArduinoOTA.setHostname(HOSTNAME);  
  ArduinoOTA.begin();  
  server.on("/", handleRoot);  
  server.on("/log", handleLog);  
  server.on("/req", handleReq);  
  server.on("/jsonOut", handleJsonOut);  
  server.on("/reboot", [](){  
   #ifdef AUTHENTICATION  
   if (!server.authenticate(www_username, www_password)) {  
    return server.requestAuthentication();  
   }  
   #endif  
   ESP.restart();  
  });  
  server.begin();   
  timeClient.begin();  
 #ifdef ENABLE_MQTT  
  mqttClient.setServer(MQTT_SERVER, MQTT_PORT);  
 #endif  
  Log("Boot event");  
 }  
 void handleLog()  
 {  
  #ifdef AUTHENTICATION  
  if (!server.authenticate(www_username, www_password)) {  
   return server.requestAuthentication();  
  }   
  #endif  
  server.send(200, "text/html", g_log.c_str());  
 }  
 void switchBaud(int newRate)  
 {  
  if(g_baudRate == newRate)  
  {  
   return;  
  }  
  if(g_baudRate != 0)  
  {  
   Serial2.flush();  
   delay(20);  
   Serial2.end();  
   delay(20);  
  }  
  char szMsg[50];  
  snprintf(szMsg, sizeof(szMsg)-1, "New baud: %d", newRate);  
  Log(szMsg);  
  Serial2.begin(newRate,SERIAL_8N1, RXD2, TXD2);  
  g_baudRate = newRate;  
  delay(20);  
 }  
 void waitForSerial()  
 {  
  for(int ix=0; ix<150;ix++)  
  {  
   if(Serial2.available()) break;  
   delay(10);  
  }  
 }  
 int readFromSerial()  
 {  
  memset(g_szRecvBuff, 0, sizeof(g_szRecvBuff));  
  int recvBuffLen = 0;  
  bool foundTerminator = true;  
  waitForSerial();  
  while(Serial2.available())  
  {  
   char szResponse[256] = "";  
   const int readNow = Serial2.readBytesUntil('>', szResponse, sizeof(szResponse)-1); //all commands terminate with "$$\r\n\rpylon>" (no new line at the end)  
   if(readNow > 0 &&   
     szResponse[0] != '\0')  
   {  
    if(readNow + recvBuffLen + 1 >= (int)(sizeof(g_szRecvBuff)))  
    {  
     Log("WARNING: Read too much data on the console!");  
     break;  
    }  
    strcat(g_szRecvBuff, szResponse);  
    recvBuffLen += readNow;  
    if(strstr(g_szRecvBuff, "$$\r\n\rpylon"))  
    {  
     strcat(g_szRecvBuff, ">"); //readBytesUntil will skip this, so re-add  
     foundTerminator = true;  
     break; //found end of the string  
    }  
    if(strstr(g_szRecvBuff, "Press [Enter] to be continued,other key to exit"))  
    {  
     //we need to send new line character so battery continues the output  
     Serial2.write("\r");  
    }  
    waitForSerial();  
   }  
  }  
  if(recvBuffLen > 0 )  
  {  
   if(foundTerminator == false)  
   {  
    Log("Failed to find pylon> terminator");  
   }  
  }  
  return recvBuffLen;  
 }  
 bool readFromSerialAndSendResponse()  
 {  
  const int recvBuffLen = readFromSerial();  
  if(recvBuffLen > 0)  
  {  
   server.sendContent(g_szRecvBuff);  
   return true;  
  }  
  return false;  
 }  
 bool sendCommandAndReadSerialResponse(const char* pszCommand)  
 {  
  switchBaud(115200);  
  if(pszCommand[0] != '\0')  
  {  
   Serial2.write(pszCommand);  
  }  
  Serial2.write("\n");  
  const int recvBuffLen = readFromSerial();  
  if(recvBuffLen > 0)  
  {  
   return true;  
  }  
  //wake up console and try again:  
  wakeUpConsole();  
  if(pszCommand[0] != '\0')  
  {  
   Serial2.write(pszCommand);  
  }  
  Serial2.write("\n");  
  return readFromSerial() > 0;  
 }  
 void handleReq()  
 {  
  #ifdef AUTHENTICATION  
  if (!server.authenticate(www_username, www_password)) {  
   return server.requestAuthentication();  
  }  
  #endif  
  bool respOK;  
  if(server.hasArg("code") == false)  
  {  
   respOK = sendCommandAndReadSerialResponse("");  
  }  
  else  
  {  
   respOK = sendCommandAndReadSerialResponse(server.arg("code").c_str());  
  }  
  handleRoot();  
 }  
 void handleJsonOut()  
 {  
  #ifdef AUTHENTICATION  
  if (!server.authenticate(www_username, www_password)) {  
   return server.requestAuthentication();  
  }  
  #endif  
  if(sendCommandAndReadSerialResponse("pwr") == false)  
  {  
   server.send(500, "text/plain", "Failed to get response to 'pwr' command");  
   return;  
  }  
  parsePwrResponse(g_szRecvBuff);  
  prepareJsonOutput(g_szRecvBuff, sizeof(g_szRecvBuff));  
  server.send(200, "application/json", g_szRecvBuff);  
 }  
 void handleRoot() {  
  #ifdef AUTHENTICATION  
  if (!server.authenticate(www_username, www_password)) {  
   return server.requestAuthentication();  
  }  
  #endif  
  timeClient.update(); //get ntp datetime  
  unsigned long days = 0, hours = 0, minutes = 0;  
  unsigned long val = os_getCurrentTimeSec();  
  days = val / (3600*24);  
  val -= days * (3600*24);  
  hours = val / 3600;  
  val -= hours * 3600;  
  minutes = val / 60;  
  val -= minutes*60;  
  time_t epochTime = timeClient.getEpochTime();  
  String formattedTime = timeClient.getFormattedTime();  
  //Get a time structure  
  struct tm *ptm = gmtime ((time_t *)&epochTime);   
  int currentMonth = ptm->tm_mon+1;  
  static char szTmp[9500] = "";   
  long timezone= GMT / 3600;  
  snprintf(szTmp, sizeof(szTmp)-1, "<html><b>Pylontech Battery LAN Interface</b><br>Time GMT: %s (%s %d)<br>Uptime: %02d:%02d:%02d.%02d<br><br>free heap: %u<br>MQTT-Server: %s<br>MQTT-root topic: %s",   
       formattedTime, "GMT ", timezone,  
       (int)days, (int)hours, (int)minutes, (int)val,   
       ESP.getFreeHeap(),MQTT_SERVER,MQTT_TOPIC_ROOT);  
  strncat(szTmp, "<BR><a href='/log'>Runtime log</a><HR>", sizeof(szTmp)-1);  
  strncat(szTmp, "<form action='/req' method='get'>Command:<input type='text' name='code'/><input type='submit'> <a href='/req?code=pwr'>PWR</a> | <a href='/req?code=pwr%201'>Power 1</a> | <a href='/req?code=pwr%202'>Power 2</a> | <a href='/req?code=pwr%203'>Power 3</a> | <a href='/req?code=pwr%204'>Power 4</a> | <a href='/req?code=help'>Help</a> | <a href='/req?code=log'>Event Log</a> | <a href='/req?code=time'>Time</a><br>", sizeof(szTmp)-1);  
  //strncat(szTmp, "<form action='/req' method='get'>Command:<input type='text' name='code'/><input type='submit'><a href='/req?code=pwr'>Power</a> | <a href='/req?code=help'>Help</a> | <a href='/req?code=log'>Event Log</a> | <a href='/req?code=time'>Time</a><br>", sizeof(szTmp)-1);  
  strncat(szTmp, "<textarea rows='80' cols='180'>", sizeof(szTmp)-1);  
  //strncat(szTmp, "<textarea rows='45' cols='180'>", sizeof(szTmp)-1);  
  strncat(szTmp, g_szRecvBuff, sizeof(szTmp)-1);  
  strncat(szTmp, "</textarea></form>", sizeof(szTmp)-1);  
  strncat(szTmp, "</html>", sizeof(szTmp)-1);  
  //send page  
  server.send(200, "text/html", szTmp);  
 }  
 unsigned long os_getCurrentTimeSec()  
 {  
  static unsigned int wrapCnt = 0;  
  static unsigned long lastVal = 0;  
  unsigned long currentVal = millis();  
  if(currentVal < lastVal)  
  {  
   wrapCnt++;  
  }  
  lastVal = currentVal;  
  unsigned long seconds = currentVal/1000;  
  //millis will wrap each 50 days, as we are interested only in seconds, let's keep the wrap counter  
  return (wrapCnt*4294967) + seconds;  
 }  
 void wakeUpConsole()  
 {  
  switchBaud(1200);  
  //byte wakeUpBuff[] = {0x7E, 0x32, 0x30, 0x30, 0x31, 0x34, 0x36, 0x38, 0x32, 0x43, 0x30, 0x30, 0x34, 0x38, 0x35, 0x32, 0x30, 0x46, 0x43, 0x43, 0x33, 0x0D};  
  //Serial.write(wakeUpBuff, sizeof(wakeUpBuff));  
  Serial.write("~20014682C0048520FCC3\r");  
  delay(1000);  
  byte newLineBuff[] = {0x0E, 0x0A};  
  switchBaud(115200);  
  for(int ix=0; ix<10; ix++)  
  {  
   Serial.write(newLineBuff, sizeof(newLineBuff));  
   delay(1000);  
   if(Serial.available())  
   {  
    while(Serial.available())  
    {  
     Serial.read();  
    }  
    break;  
   }  
  }  
 }  
 #define MAX_PYLON_BATTERIES 8  
 struct pylonBattery  
 {  
  bool isPresent;  
  long soc;   //Coulomb in %  
  long voltage; //in mW  
  long current; //in mA, negative value is discharge  
  long tempr;  //temp of case or BMS?  
  long cellTempLow;  
  long cellTempHigh;  
  long cellVoltLow;  
  long cellVoltHigh;  
  char baseState[9];  //Charge | Dischg | Idle  
  char voltageState[9]; //Normal  
  char currentState[9]; //Normal  
  char tempState[9];  //Normal  
  char time[20];    //2019-06-08 04:00:29  
  char b_v_st[9];    //Normal (battery voltage?)  
  char b_t_st[9];    //Normal (battery temperature?)  
  bool isCharging()  const { return strcmp(baseState, "Charge")  == 0; }  
  bool isDischarging() const { return strcmp(baseState, "Dischg")  == 0; }  
  bool isIdle()    const { return strcmp(baseState, "Idle")   == 0; }  
  bool isBalancing()  const { return strcmp(baseState, "Balance") == 0; }  
  bool isNormal() const  
  {  
   if(isCharging()  == false &&  
     isDischarging() == false &&  
     isIdle()    == false &&  
     isBalancing()  == false)  
   {  
    return false; //base state looks wrong!  
   }  
   return strcmp(voltageState, "Normal") == 0 &&  
       strcmp(currentState, "Normal") == 0 &&  
       strcmp(tempState,  "Normal") == 0 &&  
       strcmp(b_v_st,    "Normal") == 0 &&  
       strcmp(b_t_st,    "Normal") == 0 ;  
  }  
 };  
 struct batteryStack  
 {  
  int batteryCount;  
  int soc; //in %, if charging: average SOC, otherwise: lowest SOC  
  int temp; //in mC, if highest temp is > 15C, this will show the highest temp, otherwise the lowest  
  long currentDC;  //mAh current going in or out of the battery  
  long avgVoltage;  //in mV  
  char baseState[9]; //Charge | Dischg | Idle | Balance | Alarm!  
  pylonBattery batts[MAX_PYLON_BATTERIES];  
  bool isNormal() const  
  {  
   for(int ix=0; ix<MAX_PYLON_BATTERIES; ix++)  
   {  
    if(batts[ix].isPresent &&   
      batts[ix].isNormal() == false)  
    {  
     return false;  
    }  
   }  
   return true;  
  }  
  //in Wh  
  long getPowerDC() const  
  {  
   return (long)(((double)currentDC/1000.0)*((double)avgVoltage/1000.0));  
  }  
  // power in Wh in charge  
  float powerIN() const  
  {  
   if (currentDC > 0) {  
     return (float)(((double)currentDC/1000.0)*((double)avgVoltage/1000.0));  
   } else {  
     return (float)(0);  
   }  
  }  
  // power in Wh in discharge  
  float powerOUT() const  
  {  
   if (currentDC < 0) {  
     return (float)(((double)currentDC/1000.0)*((double)avgVoltage/1000.0)*-1);  
   } else {  
     return (float)(0);  
   }  
  }  
  //Wh estimated current on AC side (taking into account Sofar ME3000SP losses)  
  long getEstPowerAc() const  
  {  
   double powerDC = (double)getPowerDC();  
   if(powerDC == 0)  
   {  
    return 0;  
   }  
   else if(powerDC < 0)  
   {  
    //we are discharging, on AC side we will see less power due to losses  
    if(powerDC < -1000)  
    {  
     return (long)(powerDC*0.94);  
    }  
    else if(powerDC < -600)  
    {  
     return (long)(powerDC*0.90);  
    }  
    else  
    {  
     return (long)(powerDC*0.87);  
    }  
   }  
   else  
   {  
    //we are charging, on AC side we will have more power due to losses  
    if(powerDC > 1000)  
    {  
     return (long)(powerDC*1.06);  
    }  
    else if(powerDC > 600)  
    {  
     return (long)(powerDC*1.1);  
    }  
    else  
    {  
     return (long)(powerDC*1.13);  
    }  
   }  
  }  
 };  
 batteryStack g_stack;  
 long extractInt(const char* pStr, int pos)  
 {  
  return atol(pStr+pos);  
 }  
 void extractStr(const char* pStr, int pos, char* strOut, int strOutSize)  
 {  
  strOut[strOutSize-1] = '\0';  
  strncpy(strOut, pStr+pos, strOutSize-1);  
  strOutSize--;  
  //trim right  
  while(strOutSize > 0)  
  {  
   if(isspace(strOut[strOutSize-1]))  
   {  
    strOut[strOutSize-1] = '\0';  
   }  
   else  
   {  
    break;  
   }  
   strOutSize--;  
  }  
 }  
 /* Output has mixed \r and \r\n  
 pwr  
 @  
 Power Volt  Curr  Tempr Tlow  Thigh Vlow  Vhigh Base.St Volt.St Curr.St Temp.St Coulomb Time         B.V.St  B.T.St   
 1   49735 -1440 22000 19000 19000 3315  3317  Dischg  Normal  Normal  Normal  93%   2019-06-08 04:00:30 Normal  Normal   
 ....    
 8   -   -   -   -   -   -   -   Absent  -    -    -    -    -          -    -      
 Command completed successfully  
 $$  
 pylon  
 */  
 bool parsePwrResponse(const char* pStr)  
 {  
  if(strstr(pStr, "Command completed successfully") == NULL)  
  {  
   return false;  
  }  
  int chargeCnt  = 0;  
  int dischargeCnt = 0;  
  int idleCnt   = 0;  
  int alarmCnt   = 0;  
  int socAvg    = 0;  
  int socLow    = 0;  
  int tempHigh   = 0;  
  int tempLow   = 0;  
  memset(&g_stack, 0, sizeof(g_stack));  
  for(int ix=0; ix<MAX_PYLON_BATTERIES; ix++)  
  {  
   char szToFind[32] = "";  
   snprintf(szToFind, sizeof(szToFind)-1, "\r\r\n%d   ", ix+1);  
   const char* pLineStart = strstr(pStr, szToFind);  
   if(pLineStart == NULL)  
   {  
    return false;  
   }  
   pLineStart += 3; //move past \r\r\n  
   extractStr(pLineStart, 55, g_stack.batts[ix].baseState, sizeof(g_stack.batts[ix].baseState));  
   if(strcmp(g_stack.batts[ix].baseState, "Absent") == 0)  
   {  
    g_stack.batts[ix].isPresent = false;  
   }  
   else  
   {  
    g_stack.batts[ix].isPresent = true;  
    extractStr(pLineStart, 64, g_stack.batts[ix].voltageState, sizeof(g_stack.batts[ix].voltageState));  
    extractStr(pLineStart, 73, g_stack.batts[ix].currentState, sizeof(g_stack.batts[ix].currentState));  
    extractStr(pLineStart, 82, g_stack.batts[ix].tempState, sizeof(g_stack.batts[ix].tempState));  
    extractStr(pLineStart, 100, g_stack.batts[ix].time, sizeof(g_stack.batts[ix].time));  
    extractStr(pLineStart, 121, g_stack.batts[ix].b_v_st, sizeof(g_stack.batts[ix].b_v_st));  
    extractStr(pLineStart, 130, g_stack.batts[ix].b_t_st, sizeof(g_stack.batts[ix].b_t_st));  
    g_stack.batts[ix].voltage = extractInt(pLineStart, 6);  
    g_stack.batts[ix].current = extractInt(pLineStart, 13);  
    g_stack.batts[ix].tempr  = extractInt(pLineStart, 20);  
    g_stack.batts[ix].cellTempLow  = extractInt(pLineStart, 27);  
    g_stack.batts[ix].cellTempHigh  = extractInt(pLineStart, 34);  
    g_stack.batts[ix].cellVoltLow  = extractInt(pLineStart, 41);  
    g_stack.batts[ix].cellVoltHigh  = extractInt(pLineStart, 48);  
    g_stack.batts[ix].soc      = extractInt(pLineStart, 91);  
    //////////////////////////////// Post-process ////////////////////////  
    g_stack.batteryCount++;  
    g_stack.currentDC += g_stack.batts[ix].current;  
    g_stack.avgVoltage += g_stack.batts[ix].voltage;  
    socAvg += g_stack.batts[ix].soc;  
    if(g_stack.batts[ix].isNormal() == false){ alarmCnt++; }  
    else if(g_stack.batts[ix].isCharging()){chargeCnt++;}  
    else if(g_stack.batts[ix].isDischarging()){dischargeCnt++;}  
    else if(g_stack.batts[ix].isIdle()){idleCnt++;}  
    else{ alarmCnt++; } //should not really happen!  
    if(g_stack.batteryCount == 1)  
    {  
     socLow = g_stack.batts[ix].soc;  
     tempLow = g_stack.batts[ix].cellTempLow;  
     tempHigh = g_stack.batts[ix].cellTempHigh;  
    }  
    else  
    {  
     if(socLow > g_stack.batts[ix].soc){socLow = g_stack.batts[ix].soc;}  
     if(tempHigh < g_stack.batts[ix].cellTempHigh){tempHigh = g_stack.batts[ix].cellTempHigh;}  
     if(tempLow > g_stack.batts[ix].cellTempLow){tempLow = g_stack.batts[ix].cellTempLow;}  
    }  
   }  
  }  
  //now update stack state:  
  g_stack.avgVoltage /= g_stack.batteryCount;  
  g_stack.soc = socLow;  
  if(tempHigh > 15000) //15C  
  {  
   g_stack.temp = tempHigh; //in the summer we highlight the warmest cell  
  }  
  else  
  {  
   g_stack.temp = tempLow; //in the winter we focus on coldest cell  
  }  
  if(alarmCnt > 0)  
  {  
   strcpy(g_stack.baseState, "Alarm!");  
  }  
  else if(chargeCnt == g_stack.batteryCount)  
  {  
   strcpy(g_stack.baseState, "Charge");  
   g_stack.soc = (int)(socAvg / g_stack.batteryCount);  
  }  
  else if(dischargeCnt == g_stack.batteryCount)  
  {  
   strcpy(g_stack.baseState, "Dischg");  
  }  
  else if(idleCnt == g_stack.batteryCount)  
  {  
   strcpy(g_stack.baseState, "Idle");  
  }  
  else  
  {  
   strcpy(g_stack.baseState, "Balance");  
  }  
  return true;  
 }  
 void prepareJsonOutput(char* pBuff, int buffSize)  
 {  
  memset(pBuff, 0, buffSize);  
  snprintf(pBuff, buffSize-1, "{\"soc\": %d, \"temp\": %d, \"currentDC\": %ld, \"avgVoltage\": %ld, \"baseState\": \"%s\", \"batteryCount\": %d, \"powerDC\": %ld, \"estPowerAC\": %ld, \"isNormal\": %s}", g_stack.soc,   
                                                                                                       g_stack.temp,   
                                                                                                       g_stack.currentDC,   
                                                                                                       g_stack.avgVoltage,   
                                                                                                       g_stack.baseState,   
                                                                                                       g_stack.batteryCount,   
                                                                                                       g_stack.getPowerDC(),   
                                                                                                       g_stack.getEstPowerAc(),  
                                                                                                       g_stack.isNormal() ? "true" : "false");  
 }  
 void loop() {  
 #ifdef ENABLE_MQTT  
  mqttLoop();  
 #endif  
  ArduinoOTA.handle();  
  server.handleClient();  
  //if there are bytes availbe on serial here - it's unexpected  
  //when we send a command to battery, we read whole response  
  //if we get anything here anyways - we will log it  
  int bytesAv = Serial.available();  
  if(bytesAv > 0)  
  {  
   if(bytesAv > 63)  
   {  
    bytesAv = 63;  
   }  
   char buff[64+4] = "RCV:";  
   if(Serial.readBytes(buff+4, bytesAv) > 0)  
   {  
    //digitalWrite(LED, LOW);  
    digitalWrite(LED, HIGH);  
    delay(5);  
    //digitalWrite(LED, HIGH);//high is off  
    digitalWrite(LED, LOW);  
    Log(buff);  
   }  
  }  
 }  
 #ifdef ENABLE_MQTT  
 #define ABS_DIFF(a, b) (a > b ? a-b : b-a)  
 void mqtt_publish_f(const char* topic, float newValue, float oldValue, float minDiff, bool force)  
 {  
  char szTmp[16] = "";  
  snprintf(szTmp, 15, "%.2f", newValue);  
  if(force || ABS_DIFF(newValue, oldValue) > minDiff)  
  {  
   mqttClient.publish(topic, szTmp, false);  
  }  
 }  
 void mqtt_publish_i(const char* topic, int newValue, int oldValue, int minDiff, bool force)  
 {  
  char szTmp[16] = "";  
  snprintf(szTmp, 15, "%d", newValue);  
  if(force || ABS_DIFF(newValue, oldValue) > minDiff)  
  {  
   mqttClient.publish(topic, szTmp, false);  
  }  
 }  
 void mqtt_publish_s(const char* topic, const char* newValue, const char* oldValue, bool force)  
 {  
  if(force || strcmp(newValue, oldValue) != 0)  
  {  
   mqttClient.publish(topic, newValue, false);  
  }  
 }  
 void pushBatteryDataToMqtt(const batteryStack& lastSentData, bool forceUpdate /* if true - we will send all data regardless if it's the same */)  
 {  
  mqtt_publish_f(MQTT_TOPIC_ROOT "soc",     g_stack.soc,        lastSentData.soc,        0, forceUpdate);  
  mqtt_publish_f(MQTT_TOPIC_ROOT "temp",     (float)g_stack.temp/1000.0, (float)lastSentData.temp/1000.0, 0.1, forceUpdate);  
  mqtt_publish_i(MQTT_TOPIC_ROOT "currentDC",  g_stack.currentDC,     lastSentData.currentDC,     1, forceUpdate);  
  mqtt_publish_i(MQTT_TOPIC_ROOT "estPowerAC",  g_stack.getEstPowerAc(),  lastSentData.getEstPowerAc(),  10, forceUpdate);  
  mqtt_publish_i(MQTT_TOPIC_ROOT "battery_count",g_stack.batteryCount,    lastSentData.batteryCount,    0, forceUpdate);  
  mqtt_publish_s(MQTT_TOPIC_ROOT "base_state",  g_stack.baseState,     lastSentData.baseState      , forceUpdate);  
  mqtt_publish_i(MQTT_TOPIC_ROOT "is_normal",  g_stack.isNormal() ? 1:0,  lastSentData.isNormal() ? 1:0,  0, forceUpdate);  
  mqtt_publish_i(MQTT_TOPIC_ROOT "getPowerDC",  g_stack.getPowerDC(),    lastSentData.getPowerDC(),    1, forceUpdate);  
  mqtt_publish_i(MQTT_TOPIC_ROOT "powerIN",   g_stack.powerIN(),     lastSentData.powerIN(),     1, forceUpdate);  
  mqtt_publish_i(MQTT_TOPIC_ROOT "powerOUT",   g_stack.powerOUT(),     lastSentData.powerOUT(),     1, forceUpdate);  
  // publishing details  
  for (int ix = 0; ix < g_stack.batteryCount; ix++) {  
   char ixBuff[50];  
   String ixBattStr = MQTT_TOPIC_ROOT + String(ix) + "/voltage";  
   ixBattStr.toCharArray(ixBuff, 50);  
   mqtt_publish_f(ixBuff, g_stack.batts[ix].voltage / 1000.0, lastSentData.batts[ix].voltage / 1000.0, 0, forceUpdate);  
   ixBattStr = MQTT_TOPIC_ROOT + String(ix) + "/current";  
   ixBattStr.toCharArray(ixBuff, 50);  
   mqtt_publish_f(ixBuff, g_stack.batts[ix].current / 1000.0, lastSentData.batts[ix].current / 1000.0, 0, forceUpdate);  
   ixBattStr = MQTT_TOPIC_ROOT + String(ix) + "/soc";  
   ixBattStr.toCharArray(ixBuff, 50);  
   mqtt_publish_i(ixBuff, g_stack.batts[ix].soc, lastSentData.batts[ix].soc, 0, forceUpdate);  
   ixBattStr = MQTT_TOPIC_ROOT + String(ix) + "/charging";  
   ixBattStr.toCharArray(ixBuff, 50);  
   mqtt_publish_i(ixBuff, g_stack.batts[ix].isCharging()?1:0, lastSentData.batts[ix].isCharging()?1:0, 0, forceUpdate);  
   ixBattStr = MQTT_TOPIC_ROOT + String(ix) + "/discharging";  
   ixBattStr.toCharArray(ixBuff, 50);  
   mqtt_publish_i(ixBuff, g_stack.batts[ix].isDischarging()?1:0, lastSentData.batts[ix].isDischarging()?1:0, 0, forceUpdate);  
   ixBattStr = MQTT_TOPIC_ROOT + String(ix) + "/idle";  
   ixBattStr.toCharArray(ixBuff, 50);  
   mqtt_publish_i(ixBuff, g_stack.batts[ix].isIdle()?1:0, lastSentData.batts[ix].isIdle()?1:0, 0, forceUpdate);  
   ixBattStr = MQTT_TOPIC_ROOT + String(ix) + "/state";  
   ixBattStr.toCharArray(ixBuff, 50);  
   mqtt_publish_s(ixBuff, g_stack.batts[ix].isIdle()?"Idle":g_stack.batts[ix].isCharging()?"Charging":g_stack.batts[ix].isDischarging()?"Discharging":"", lastSentData.batts[ix].isIdle()?"Idle":lastSentData.batts[ix].isCharging()?"Charging":lastSentData.batts[ix].isDischarging()?"Discharging":"", forceUpdate);  
   ixBattStr = MQTT_TOPIC_ROOT + String(ix) + "/temp";  
   ixBattStr.toCharArray(ixBuff, 50);  
   mqtt_publish_f(ixBuff, (float)g_stack.batts[ix].tempr/1000.0, (float)lastSentData.batts[ix].tempr/1000.0, 0.1, forceUpdate);  
  }  
 }   
 void mqttLoop()  
 {  
  //if we have problems with connecting to mqtt server, we will attempt to re-estabish connection each 1minute (not more than that)  
  static unsigned long g_lastConnectionAttempt = 0;  
  //first: let's make sure we are connected to mqtt  
  const char* topicLastWill = MQTT_TOPIC_ROOT "availability";  
  if (!mqttClient.connected() && (g_lastConnectionAttempt == 0 || os_getCurrentTimeSec() - g_lastConnectionAttempt > 60)) {  
   if(mqttClient.connect(HOSTNAME, MQTT_USER, MQTT_PASSWORD, topicLastWill, 1, true, "offline"))  
   {  
    Log("Connected to MQTT server: " MQTT_SERVER);  
    mqttClient.publish(topicLastWill, "online", true);  
   }  
   else  
   {  
    Log("Failed to connect to MQTT server.");  
   }  
   g_lastConnectionAttempt = os_getCurrentTimeSec();  
  }  
  //next: read data from battery and send via MQTT (but only once per MQTT_PUSH_FREQ_SEC seconds)  
  static unsigned long g_lastDataSent = 0;  
  if(mqttClient.connected() &&   
    os_getCurrentTimeSec() - g_lastDataSent > MQTT_PUSH_FREQ_SEC &&  
    sendCommandAndReadSerialResponse("pwr") == true)  
  {  
   static batteryStack lastSentData; //this is the last state we sent to MQTT, used to prevent sending the same data over and over again  
   static unsigned int callCnt = 0;  
   parsePwrResponse(g_szRecvBuff);  
   bool forceUpdate = (callCnt % 20 == 0); //push all the data every 20th call  
   pushBatteryDataToMqtt(lastSentData, forceUpdate);  
   callCnt++;  
   g_lastDataSent = os_getCurrentTimeSec();  
   memcpy(&lastSentData, &g_stack, sizeof(batteryStack));  
  }  
  mqttClient.loop();  
 }  
 #endif //ENABLE_MQTT  

Pylontech PV-Akkustatus im HomeAssistant

Loading

Update 26.11.2024:
Aufgrund vieler Anfragen nach der Platine und weil vielleicht nicht jeder daran interessiert ist, sie selber zu zeichnen, stelle ich die Gerberdaten zum Dowload zur Verfügung:

pylontec_mqttinterface_v1.0_gerber

Wer eine Photovoltaik Anlage in seinem Eigenheim aufgebaut hat, verwendet vielleicht sogar einen Energiespeicher. In diesem Beispiel handelt es sich um eine Offgrid Anlage, die mit zwei Modulen des Herstellers Pylontech ausgestattet ist. Die Pylontech Akkus der Type US3000C haben eine Ausgangs Spannung von 48V. Die Nennkapazität beträgt 3500Wh. Die verbauten Zellen sind LiFePO4 Zellen und die nutzbare Kapazität ist laut Datenblatt mit 3374Wh angegeben. Die Akkus sind so gebaut, dass sie mit weiteren Pylontech-Akkus parallelgeschaltet werden können. Das intern verbaute BMS (BatterieManagementSystem) kommuniziert über eine sogenannte „Link“ Schnittstelle mit den anderen Pylontech Batteriemodulen. Ein als „Master“ konfigurierter Akku erledigt den Datenaustausch zum Wechselrichter. Hier stellt Pylontech den CAN- oder RS485 Bus als Schnittstelle zur Verfügung. Will man jedoch Informationen über die einzelnen Zellen (Spannungen, Ströme, Ladungen, Temperaturen etc.) haben, so gibt es an jedem Modul noch eine Schnittstelle mit der Bezeichnung „Console“. Das ist eine RS232 Schnittstelle über die man direkt mit dem BMS des Akkus kommunizieren kann. Dieser Port wird auch genutzt, um die Firmware des BMS zu aktualisieren. Ich rate aber DRINGEND davon ab, mit Firmwareupdates und Flashsoftware daran herum zu spielen. Das ist dem Hersteller oder dem Haftungsträger vorbehalten.

Da man über diese Schnittstelle aber auch einiges an Informationen über die im Akku verbauten Zellen erfährt, ist das ein interessanter Zugang. So hatte ich anfangs einen Laptop mit einem Terminal angeschlossen und konnte so die einzelnen Zellenspannungen und vor allem den evtl. unterschiedlichen Ladezustand der parallel geschalteten Module entdecken und monitoren. So dachte ich mir, wäre es doch eine gute Idee, wenn man diese Infos in seiner Hausautomatisierung zur Verfügung hat und dort visualisieren und für Steuerungszwecke nutzen kann.

Da für uns Freaks und Technikinteressierte Begriffe wie Homeassistant, Docker, Proxmox, HomeMatic, NodeRed usw. durchaus bekannt sind, dachte ich mir, diese Daten sollen auch im Homeassistant zu Entitäten werden. So war schnell wieder ein kleines neues Projekt geschaffen. Mein Plan: die Daten der Serien Schnittstelle auslesen und per MQTT an den Homeassistant senden.

Bevor ich mich nun aber mit dem Zerlegen der Datenstrings, die über den seriellen Port herauspurzeln beschäftige, bemühte ich zuerst einmal die Suchmaschinen. Vielleicht hat sich ja schon jemand anderes mit diesem Thema beschäftigt.  Und genau so war es auch. Auf GitHub wurde ich unter dem Begriff „pylontec2mqtt“ fündig. Unter https://github.com/irekzielinski/Pylontech-Battery-Monitoring ist ein Projekt gehostet, das mittels ESP8266 die seriellen Daten vom Port abholt und per MQTT und Wifi zum Homeassistant Server sendet. Ein Fork mit einer Weiterentwicklung dieses Projektes ist unter https://github.com/hidaba/PylontechMonitoring zu finden.

Warum ich das Projekt trotz des einfachen Nachbaus hier im Blog veröffentliche? Ich habe die Schaltung ein wenig optimiert und in ein Layout gepackt und den Code etwas angepasst. Das Ergebnis möchte ich hier teilen. Mir war es in wichtig, einen vernünftigen Aufbau auf einer Platine zu haben, die mit einem USB A-B Kabel für die Spannungsversorgung und einem LAN-RJ45 Kabel für die Datenverbindung angesteckt wird. Dabei wollte ich eine „solide“ USB Steckverbindung verwenden (nicht die Fragilen Mini- oder Mikro USB Steckverbindungen)

Auf einer Lochrasterplatine und mit den üblichen Entwicklungsplatinen habe ich mir auf die Schnelle ein Funktionsmuster „zusammen gestrickt“ um darauf die Software anpassen zu können.

Funktionsmuster auf Lochraster

So habe ich zuerst aus den Skizzen im Git-Projekt einen Schaltplan erstellt. An der „Console“ Schnittstelle liegt ein „echter“ RS232 Pegel an, der über den MAX3232 IC auf eine 5V TTL umgesetzt wird. Mit dem BSS123 FET wird für die Signale RX und TX je ein Pegelwandler auf 3.3V realisiert.

pylontec2mqtt schematic

Diese 3.3V TTL Pegel verarbeitet der ESP8266 in Form des Wemos D1Mini oder WemosD1Pro Entwicklungsboard, das auf die Platine aufgesteckt wird. Die gesamte Konstruktion habe ich dann in ein kleines Kunststoffgehäuse gepackt, das bequem über die Lan und USB Kabel mit dem Pylontec und einer USB Spannungsquelle zu verbinden ist.

Layout im Designtool

Im Bild oben ist der Layout Entwurf dargestellt. Die Platine und die Lage der Bauteile wurden vor der Fertigung nochmals mit der Vorschau überprüft und dann beim Hersteller des Vertrauens bestellt.

Vorschau der Platine vor der Fertigung

Nach kaum zwei Wochen Wartezeit hielt ich dann die Leeren Platinen in Händen und konnte sie mit den Bauteilen bestücken.

fertig bestückte Platine

Im Bild oben ist die fertig bestückte Platine zu sehen. Hier fehlt nur mehr das Wemosboard mit dem ESP.

Vergleich zwischen Funktionsmuster und erstem „Fertigungsmodell“

Schlussendlich habe ich einen WemosD1 Pro aufgesteckt, da dieser die Möglichkeit bietet, eine externe WiFi Antenne anzuschließen und somit eine vernünftige Funkreichweite zu erhalten.

Nach dem Aufspielen der Software und der Inbetriebnahme ist der Webserver des Wemos unter der im Code angegebenen IP Adresse erreichbar. Hier kann auch gleich geprüft werden, ob der Pylontech Akku mit dem Wemos kommuniziert. Das Ergebnis sieht dann wie folgt aus.

Webseite des WEMOS

Hier ist zu erkennen, dass beide Akkumodule korrekt erkannt werden. Im nächsten Schritt wird überprüft, ob über das MQTT Protokol Nachrichten versandt werden. Die IP Adresse des MQTT Brokers ist auch im Code des Wemos festzulegen. Ich habe in meinem Aufbau den MQTT-Explorer im Homeassistant eingerichtet, um schnell und einfach die MQTT Funktionen prüfen zu können.

MQTT Explorer

Im Bild oben ist zu erkennen, dass auch die Daten über MQTT korrekt ankommen. Jetzt ist es nur mehr notwendig, im Homeassistant ein Sensor yaml file anzulegen, um die topics im als Entitäten zur Verfügung zu stellen.  Dazu habe ich im configuration.yaml folgenden code hinzugefügt:

mqtt:
  sensor:
#Pylontec Akku Serial Readout (ESP32 192.168.xxx.yyy)
    - state_topic: "ingmarsretro/pylontec/ESP_WiFi_RSSI"
      name: "Pylontec_RSSI"
      unit_of_measurement: dB
      
    - state_topic: "ingmarsretro/pylontec/availability"
      name: "Pylontec_Status"
      
    - state_topic: "ingmarsretro/pylontec/currentDC"
      name: "DC-Strom"
      unit_of_measurement: "mA"
      
    - state_topic: "ingmarsretro/pylontec/getPowerDC"  
      name: "getPower DC"
      unit_of_measurement: "W"
      
    - state_topic: "ingmarsretro/pylontec/powerIN"  
      name: "Power IN"
      unit_of_measurement: "W"  
      
    - state_topic: "ingmarsretro/pylontec/estPowerAC"
      name: "Pylontec_estPowerAC"
      unit_of_measurement: Watt  
      
    - state_topic: "ingmarsretro/pylontec/soc"
      name: "Pylontec_SOC"
      unit_of_measurement: "%"  
      
    - state_topic: "ingmarsretro/pylontec/temp"
      name: "Pylontec_temperature"
      unit_of_measurement: "°C"
      
    - state_topic: "ingmarsretro/pylontec/battery_count"
      name: "Pylontec_BatteryCount"
      unit_of_measurement: "pcs"      
      
    - state_topic: "ingmarsretro/pylontec/base_state"
      name: "Pylontec_BaseState"
      
    - state_topic: "ingmarsretro/pylontec/is_normal"  
      name: "Pylontec_is_normal"

    - state_topic: "ingmarsretro/pylontec/powerOUT"
      name: "Pylontec_powerOUT"
      unit_of_measurement: Watt
      
# Pylontech battery module 0      
      
    - state_topic: "ingmarsretro/pylontec/0/current"
      name: "Pylontec_Battery0_current"
      unit_of_measurement: "A"

    - state_topic: "ingmarsretro/pylontec/0/voltage"
      name: "Pylontec_Battery0_voltage"
      unit_of_measurement: "V"
      
    - state_topic: "ingmarsretro/pylontec/0/soc"
      name: "Pylontec_Battery0_soc"
      unit_of_measurement: "%"
      
    - state_topic: "ingmarsretro/pylontec/0/charging"
      name: "Pylontec_Battery0_charging"
      
    - state_topic: "ingmarsretro/pylontec/0/discharging"
      name: "Pylontec_Battery0_discharging"
     
    - state_topic: "ingmarsretro/pylontec/0/idle"
      name: "Pylontec_Battery0_idle"
      
    - state_topic: "ingmarsretro/pylontec/0/state"
      name: "Pylontec_Battery0_state"
      
    - state_topic: "ingmarsretro/pylontec/0/temp"
      name: "Pylontec_Battery0_temp"
      unit_of_measurement: "°C"
      
# Pylontech battery module 1      
      
    - state_topic: "ingmarsretro/pylontec/1/current"
      name: "Pylontec_Battery1_current"
      unit_of_measurement: "A"

    - state_topic: "ingmarsretro/pylontec/1/voltage"
      name: "Pylontec_Battery1_voltage"
      unit_of_measurement: "V"
      
    - state_topic: "ingmarsretro/pylontec/1/soc"
      name: "Pylontec_Battery1_soc"
      unit_of_measurement: "%"
      
    - state_topic: "ingmarsretro/pylontec/1/charging"
      name: "Pylontec_Battery1_charging"
      
    - state_topic: "ingmarsretro/pylontec/1/discharging"
      name: "Pylontec_Battery1_discharging"
     
    - state_topic: "ingmarsretro/pylontec/1/idle"
      name: "Pylontec_Battery1_idle"
      
    - state_topic: "ingmarsretro/pylontec/1/state"
      name: "Pylontec_Battery1_state"
      
    - state_topic: "ingmarsretro/pylontec/1/temp"
      name: "Pylontec_Battery1_temp"
      unit_of_measurement: "°C"
      

Auf der Homeassistant Website könnte die Visualisierung dann beispielsweise so aussehen:

Zu guter Letzt poste ich unten noch den angepassten Code. Die zum Kompilieren notwendigen Libraries und weiteren Infos sind den oben angeführten GitHub Links zu entnehmen.

#include <ESP8266WiFi.h>
#include <ESP8266mDNS.h>
#include <ArduinoOTA.h>
#include <ESP8266WebServer.h>
#include <circular_log.h>
#include <ArduinoJson.h>
#include <NTPClient.h>
#include <ESP8266TimerInterrupt.h>

//+++ START CONFIGURATION +++

//IMPORTANT: Specify your WIFI settings:
#define WIFI_SSID "wifiname"
#define WIFI_PASS deinpasswort1234"
#define WIFI_HOSTNAME "mppsolar-pylontec"

//Uncomment for static ip configuration
#define STATIC_IP
  IPAddress local_IP(192, 168, xxx, yyy);
  IPAddress subnet(255, 255, 255, 0);
  IPAddress gateway(192, 168, xxx, zzz);
  IPAddress primaryDNS(192, 168, xxx, zzz);

//Uncomment for authentication page
//#define AUTHENTICATION
//set http Authentication
const char* www_username = "admin";
const char* www_password = "password";


//IMPORTANT: Uncomment this line if you want to enable MQTT (and fill correct MQTT_ values below):
#define ENABLE_MQTT

// Set offset time in seconds to adjust for your timezone, for example:
// GMT +1 = 3600
// GMT +1 = 7200
// GMT +8 = 28800
// GMT -1 = -3600
// GMT 0 = 0
#define GMT 3600

//NOTE 1: if you want to change what is pushed via MQTT - edit function: pushBatteryDataToMqtt.
//NOTE 2: MQTT_TOPIC_ROOT is where battery will push MQTT topics. For example "soc" will be pushed to: "home/grid_battery/soc"
#define MQTT_SERVER        "192.168.xx.broker"
#define MQTT_PORT          1883
#define MQTT_USER          ""
#define MQTT_PASSWORD      ""
#define MQTT_TOPIC_ROOT    "ingmarsretro/pylontec/"  //this is where mqtt data will be pushed
#define MQTT_PUSH_FREQ_SEC 2  //maximum mqtt update frequency in seconds

//+++   END CONFIGURATION +++

#ifdef ENABLE_MQTT
#include <PubSubClient.h>
WiFiClient espClient;
PubSubClient mqttClient(espClient);
#endif //ENABLE_MQTT

//text response
char g_szRecvBuff[7000];

const long utcOffsetInSeconds = GMT;
char daysOfTheWeek[7][12] = {"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"};
// Define NTP Client to get time
WiFiUDP ntpUDP;
NTPClient timeClient(ntpUDP, "pool.ntp.org", utcOffsetInSeconds);


ESP8266WebServer server(80);
circular_log<7000> g_log;
bool ntpTimeReceived = false;
int g_baudRate = 0;

void Log(const char* msg)
{
  g_log.Log(msg);
}

//Define Interrupt Timer to Calculate Power meter every second (kWh)
#define USING_TIM_DIV1 true                                             // for shortest and most accurate timer
ESP8266Timer ITimer;
bool setInterval(unsigned long interval, timer_callback callback);      // interval (in microseconds)
#define TIMER_INTERVAL_MS 1000

//Global Variables for the Power Meter - accessible from the calculating interrupt und from main
unsigned long powerIN = 0;       //WS gone in to the BAttery
unsigned long powerOUT = 0;      //WS gone out of the Battery
//Global Variables for the Power Meter - Überlauf
unsigned long powerINWh = 0;       //WS gone in to the BAttery
unsigned long powerOUTWh = 0;      //WS gone out of the Battery

void setup() {
  
  memset(g_szRecvBuff, 0, sizeof(g_szRecvBuff)); //clean variable
  
  pinMode(LED_BUILTIN, OUTPUT); 
  digitalWrite(LED_BUILTIN, HIGH);//high is off
  
  // put your setup code here, to run once:
  WiFi.mode(WIFI_STA);
  WiFi.persistent(false); //our credentialss are hardcoded, so we don't need ESP saving those each boot (will save on flash wear)
  WiFi.hostname(WIFI_HOSTNAME);
  #ifdef STATIC_IP
     WiFi.config(local_IP, gateway, subnet, primaryDNS);
  #endif
  WiFi.begin(WIFI_SSID, WIFI_PASS);

  for(int ix=0; ix<10; ix++)
  {
    Log("Wait for WIFI Connection");
    if(WiFi.status() == WL_CONNECTED)
    {
      break;
    }

    delay(1000);
  }

  ArduinoOTA.setHostname(WIFI_HOSTNAME);
  ArduinoOTA.begin();
  server.on("/", handleRoot);
  server.on("/log", handleLog);
  server.on("/req", handleReq);
  server.on("/jsonOut", handleJsonOut);
  server.on("/reboot", [](){
    #ifdef AUTHENTICATION
    if (!server.authenticate(www_username, www_password)) {
      return server.requestAuthentication();
    }
    #endif
    ESP.restart();
  });
  
  server.begin(); 
  
  timeClient.begin();

  
#ifdef ENABLE_MQTT
  mqttClient.setServer(MQTT_SERVER, MQTT_PORT);
#endif

  Log("Boot event");
  
}

void handleLog()
{
  #ifdef AUTHENTICATION
  if (!server.authenticate(www_username, www_password)) {
    return server.requestAuthentication();
  } 
  #endif
  server.send(200, "text/html", g_log.c_str());
}

void switchBaud(int newRate)
{
  if(g_baudRate == newRate)
  {
    return;
  }
  
  if(g_baudRate != 0)
  {
    Serial.flush();
    delay(20);
    Serial.end();
    delay(20);
  }

  char szMsg[50];
  snprintf(szMsg, sizeof(szMsg)-1, "New baud: %d", newRate);
  Log(szMsg);
  
  Serial.begin(newRate);
  g_baudRate = newRate;

  delay(20);
}

void waitForSerial()
{
  for(int ix=0; ix<150;ix++)
  {
    if(Serial.available()) break;
    delay(10);
  }
}

int readFromSerial()
{
  memset(g_szRecvBuff, 0, sizeof(g_szRecvBuff));
  int recvBuffLen = 0;
  bool foundTerminator = true;
  
  waitForSerial();
  
  while(Serial.available())
  {
    char szResponse[256] = "";
    const int readNow = Serial.readBytesUntil('>', szResponse, sizeof(szResponse)-1); //all commands terminate with "$$\r\n\rpylon>" (no new line at the end)
    if(readNow > 0 && 
       szResponse[0] != '\0')
    {
      if(readNow + recvBuffLen + 1 >= (int)(sizeof(g_szRecvBuff)))
      {
        Log("WARNING: Read too much data on the console!");
        break;
      }
      
      strcat(g_szRecvBuff, szResponse);
      recvBuffLen += readNow;

      if(strstr(g_szRecvBuff, "$$\r\n\rpylon"))
      {
        strcat(g_szRecvBuff, ">"); //readBytesUntil will skip this, so re-add
        foundTerminator = true;
        break; //found end of the string
      }

      if(strstr(g_szRecvBuff, "Press [Enter] to be continued,other key to exit"))
      {
        //we need to send new line character so battery continues the output
        Serial.write("\r");
      }

      waitForSerial();
    }
  }

  if(recvBuffLen > 0 )
  {
    if(foundTerminator == false)
    {
      Log("Failed to find pylon> terminator");
    }
  }

  return recvBuffLen;
}

bool readFromSerialAndSendResponse()
{
  const int recvBuffLen = readFromSerial();
  if(recvBuffLen > 0)
  {
    server.sendContent(g_szRecvBuff);
    return true;
  }

  return false;
}

bool sendCommandAndReadSerialResponse(const char* pszCommand)
{
  switchBaud(115200);

  if(pszCommand[0] != '\0')
  {
    Serial.write(pszCommand);
  }
  Serial.write("\n");

  const int recvBuffLen = readFromSerial();
  if(recvBuffLen > 0)
  {
    return true;
  }

  //wake up console and try again:
  wakeUpConsole();

  if(pszCommand[0] != '\0')
  {
    Serial.write(pszCommand);
  }
  Serial.write("\n");

  return readFromSerial() > 0;
}

void handleReq()
{
  #ifdef AUTHENTICATION
  if (!server.authenticate(www_username, www_password)) {
    return server.requestAuthentication();
  }
  #endif
  bool respOK;
  if(server.hasArg("code") == false)
  {
    respOK = sendCommandAndReadSerialResponse("");
  }
  else
  {
    respOK = sendCommandAndReadSerialResponse(server.arg("code").c_str());
  }

  handleRoot();
}



void handleJsonOut()
{
  #ifdef AUTHENTICATION
  if (!server.authenticate(www_username, www_password)) {
    return server.requestAuthentication();
  }
  #endif
  if(sendCommandAndReadSerialResponse("pwr") == false)
  {
    server.send(500, "text/plain", "Failed to get response to 'pwr' command");
    return;
  }

  parsePwrResponse(g_szRecvBuff);
  prepareJsonOutput(g_szRecvBuff, sizeof(g_szRecvBuff));
  server.send(200, "application/json", g_szRecvBuff);
}

void handleRoot() {
  #ifdef AUTHENTICATION
  if (!server.authenticate(www_username, www_password)) {
    return server.requestAuthentication();
  }
  #endif
  timeClient.update(); //get ntp datetime
  unsigned long days = 0, hours = 0, minutes = 0;
  unsigned long val = os_getCurrentTimeSec();
  days = val / (3600*24);
  val -= days * (3600*24);
  hours = val / 3600;
  val -= hours * 3600;
  minutes = val / 60;
  val -= minutes*60;

  time_t epochTime = timeClient.getEpochTime();
  String formattedTime = timeClient.getFormattedTime();
  //Get a time structure
  struct tm *ptm = gmtime ((time_t *)&epochTime); 
  int currentMonth = ptm->tm_mon+1;

  static char szTmp[9500] = "";  
  long timezone= GMT / 3600;
  snprintf(szTmp, sizeof(szTmp)-1, "<html><b>Pylontech Battery</b><br>Time GMT: %s (%s %d)<br>Uptime: %02d:%02d:%02d.%02d<br><br>free heap: %u<br>Wifi RSSI: %d<BR>Wifi SSID: %s", 
            formattedTime, "GMT ", timezone,
            (int)days, (int)hours, (int)minutes, (int)val, 
            ESP.getFreeHeap(), WiFi.RSSI(), WiFi.SSID().c_str());

  strncat(szTmp, "<BR><a href='/log'>Runtime log</a><HR>", sizeof(szTmp)-1);
  strncat(szTmp, "<form action='/req' method='get'>Command:<input type='text' name='code'/><input type='submit'> <a href='/req?code=pwr'>PWR</a> | <a href='/req?code=pwr%201'>Power 1</a> |  <a href='/req?code=pwr%202'>Power 2</a> | <a href='/req?code=pwr%203'>Power 3</a> | <a href='/req?code=pwr%204'>Power 4</a> | <a href='/req?code=help'>Help</a> | <a href='/req?code=log'>Event Log</a> | <a href='/req?code=time'>Time</a><br>", sizeof(szTmp)-1);
  //strncat(szTmp, "<form action='/req' method='get'>Command:<input type='text' name='code'/><input type='submit'><a href='/req?code=pwr'>Power</a> | <a href='/req?code=help'>Help</a> | <a href='/req?code=log'>Event Log</a> | <a href='/req?code=time'>Time</a><br>", sizeof(szTmp)-1);
  strncat(szTmp, "<textarea rows='80' cols='180'>", sizeof(szTmp)-1);
  //strncat(szTmp, "<textarea rows='45' cols='180'>", sizeof(szTmp)-1);
  strncat(szTmp, g_szRecvBuff, sizeof(szTmp)-1);
  strncat(szTmp, "</textarea></form>", sizeof(szTmp)-1);
  strncat(szTmp, "</html>", sizeof(szTmp)-1);
  //send page
  server.send(200, "text/html", szTmp);
}

unsigned long os_getCurrentTimeSec()
{
  static unsigned int wrapCnt = 0;
  static unsigned long lastVal = 0;
  unsigned long currentVal = millis();

  if(currentVal < lastVal)
  {
    wrapCnt++;
  }

  lastVal = currentVal;
  unsigned long seconds = currentVal/1000;
  
  //millis will wrap each 50 days, as we are interested only in seconds, let's keep the wrap counter
  return (wrapCnt*4294967) + seconds;
}

void wakeUpConsole()
{
  switchBaud(1200);

  //byte wakeUpBuff[] = {0x7E, 0x32, 0x30, 0x30, 0x31, 0x34, 0x36, 0x38, 0x32, 0x43, 0x30, 0x30, 0x34, 0x38, 0x35, 0x32, 0x30, 0x46, 0x43, 0x43, 0x33, 0x0D};
  //Serial.write(wakeUpBuff, sizeof(wakeUpBuff));
  Serial.write("~20014682C0048520FCC3\r");
  delay(1000);

  byte newLineBuff[] = {0x0E, 0x0A};
  switchBaud(115200);
  
  for(int ix=0; ix<10; ix++)
  {
    Serial.write(newLineBuff, sizeof(newLineBuff));
    delay(1000);

    if(Serial.available())
    {
      while(Serial.available())
      {
        Serial.read();
      }
      
      break;
    }
  }
}

#define MAX_PYLON_BATTERIES 8

struct pylonBattery
{
  bool isPresent;
  long  soc;     //Coulomb in %
  long  voltage; //in mW
  long  current; //in mA, negative value is discharge
  long  tempr;   //temp of case or BMS?
  long  cellTempLow;
  long  cellTempHigh;
  long  cellVoltLow;
  long  cellVoltHigh;
  char baseState[9];    //Charge | Dischg | Idle
  char voltageState[9]; //Normal
  char currentState[9]; //Normal
  char tempState[9];    //Normal
  char time[20];        //2019-06-08 04:00:29
  char b_v_st[9];       //Normal  (battery voltage?)
  char b_t_st[9];       //Normal  (battery temperature?)

  bool isCharging()    const { return strcmp(baseState, "Charge")   == 0; }
  bool isDischarging() const { return strcmp(baseState, "Dischg")   == 0; }
  bool isIdle()        const { return strcmp(baseState, "Idle")     == 0; }
  bool isBalancing()   const { return strcmp(baseState, "Balance")  == 0; }
  

  bool isNormal() const
  {
    if(isCharging()    == false &&
       isDischarging() == false &&
       isIdle()        == false &&
       isBalancing()   == false)
    {
      return false; //base state looks wrong!
    }

    return  strcmp(voltageState, "Normal") == 0 &&
            strcmp(currentState, "Normal") == 0 &&
            strcmp(tempState,    "Normal") == 0 &&
            strcmp(b_v_st,       "Normal") == 0 &&
            strcmp(b_t_st,       "Normal") == 0 ;
  }
};

struct batteryStack
{
  int batteryCount;
  int soc;  //in %, if charging: average SOC, otherwise: lowest SOC
  int temp; //in mC, if highest temp is > 15C, this will show the highest temp, otherwise the lowest
  long currentDC;    //mAh current going in or out of the battery
  long avgVoltage;    //in mV
  char baseState[9];  //Charge | Dischg | Idle | Balance | Alarm!

  
  pylonBattery batts[MAX_PYLON_BATTERIES];

  bool isNormal() const
  {
    for(int ix=0; ix<MAX_PYLON_BATTERIES; ix++)
    {
      if(batts[ix].isPresent && 
         batts[ix].isNormal() == false)
      {
        return false;
      }
    }

    return true;
  }

  //in Wh
  long getPowerDC() const
  {
    return (long)(((double)currentDC/1000.0)*((double)avgVoltage/1000.0));
  }

  // power in Wh in charge
  float powerIN() const
  {
    if (currentDC > 0) {
       return (float)(((double)currentDC/1000.0)*((double)avgVoltage/1000.0));
    } else {
       return (float)(0);
    }
  }
  
  // power in Wh in discharge
  float powerOUT() const
  {
    if (currentDC < 0) {
       return (float)(((double)currentDC/1000.0)*((double)avgVoltage/1000.0)*-1);
    } else {
       return (float)(0);
    }
  }

  //Wh estimated current on AC side (taking into account Sofar ME3000SP losses)
  long getEstPowerAc() const
  {
    double powerDC = (double)getPowerDC();
    if(powerDC == 0)
    {
      return 0;
    }
    else if(powerDC < 0)
    {
      //we are discharging, on AC side we will see less power due to losses
      if(powerDC < -1000)
      {
        return (long)(powerDC*0.94);
      }
      else if(powerDC < -600)
      {
        return (long)(powerDC*0.90);
      }
      else
      {
        return (long)(powerDC*0.87);
      }
    }
    else
    {
      //we are charging, on AC side we will have more power due to losses
      if(powerDC > 1000)
      {
        return (long)(powerDC*1.06);
      }
      else if(powerDC > 600)
      {
        return (long)(powerDC*1.1);
      }
      else
      {
        return (long)(powerDC*1.13);
      }
    }
  }
};

batteryStack g_stack;


long extractInt(const char* pStr, int pos)
{
  return atol(pStr+pos);
}

void extractStr(const char* pStr, int pos, char* strOut, int strOutSize)
{
  strOut[strOutSize-1] = '\0';
  strncpy(strOut, pStr+pos, strOutSize-1);
  strOutSize--;
  
  
  //trim right
  while(strOutSize > 0)
  {
    if(isspace(strOut[strOutSize-1]))
    {
      strOut[strOutSize-1] = '\0';
    }
    else
    {
      break;
    }

    strOutSize--;
  }
}

/* Output has mixed \r and \r\n
pwr

@

Power Volt   Curr   Tempr  Tlow   Thigh  Vlow   Vhigh  Base.St  Volt.St  Curr.St  Temp.St  Coulomb  Time                 B.V.St   B.T.St  

1     49735  -1440  22000  19000  19000  3315   3317   Dischg   Normal   Normal   Normal   93%      2019-06-08 04:00:30  Normal   Normal  

....   

8     -      -      -      -      -      -      -      Absent   -        -        -        -        -                    -        -       

Command completed successfully

$$

pylon
*/
bool parsePwrResponse(const char* pStr)
{
  if(strstr(pStr, "Command completed successfully") == NULL)
  {
    return false;
  }
  
  int chargeCnt    = 0;
  int dischargeCnt = 0;
  int idleCnt      = 0;
  int alarmCnt     = 0;
  int socAvg       = 0;
  int socLow       = 0;
  int tempHigh     = 0;
  int tempLow      = 0;

  memset(&g_stack, 0, sizeof(g_stack));

  for(int ix=0; ix<MAX_PYLON_BATTERIES; ix++)
  {
    char szToFind[32] = "";
    snprintf(szToFind, sizeof(szToFind)-1, "\r\r\n%d     ", ix+1);

    const char* pLineStart = strstr(pStr, szToFind);
    if(pLineStart == NULL)
    {
      return false;
    }

    pLineStart += 3; //move past \r\r\n

    extractStr(pLineStart, 55, g_stack.batts[ix].baseState, sizeof(g_stack.batts[ix].baseState));
    if(strcmp(g_stack.batts[ix].baseState, "Absent") == 0)
    {
      g_stack.batts[ix].isPresent = false;
    }
    else
    {
      g_stack.batts[ix].isPresent = true;
      extractStr(pLineStart, 64, g_stack.batts[ix].voltageState, sizeof(g_stack.batts[ix].voltageState));
      extractStr(pLineStart, 73, g_stack.batts[ix].currentState, sizeof(g_stack.batts[ix].currentState));
      extractStr(pLineStart, 82, g_stack.batts[ix].tempState, sizeof(g_stack.batts[ix].tempState));
      extractStr(pLineStart, 100, g_stack.batts[ix].time, sizeof(g_stack.batts[ix].time));
      extractStr(pLineStart, 121, g_stack.batts[ix].b_v_st, sizeof(g_stack.batts[ix].b_v_st));
      extractStr(pLineStart, 130, g_stack.batts[ix].b_t_st, sizeof(g_stack.batts[ix].b_t_st));
      g_stack.batts[ix].voltage = extractInt(pLineStart, 6);
      g_stack.batts[ix].current = extractInt(pLineStart, 13);
      g_stack.batts[ix].tempr   = extractInt(pLineStart, 20);
      g_stack.batts[ix].cellTempLow    = extractInt(pLineStart, 27);
      g_stack.batts[ix].cellTempHigh   = extractInt(pLineStart, 34);
      g_stack.batts[ix].cellVoltLow    = extractInt(pLineStart, 41);
      g_stack.batts[ix].cellVoltHigh   = extractInt(pLineStart, 48);
      g_stack.batts[ix].soc            = extractInt(pLineStart, 91);

      //////////////////////////////// Post-process ////////////////////////
      g_stack.batteryCount++;
      g_stack.currentDC += g_stack.batts[ix].current;
      g_stack.avgVoltage += g_stack.batts[ix].voltage;
      socAvg += g_stack.batts[ix].soc;

      if(g_stack.batts[ix].isNormal() == false){ alarmCnt++; }
      else if(g_stack.batts[ix].isCharging()){chargeCnt++;}
      else if(g_stack.batts[ix].isDischarging()){dischargeCnt++;}
      else if(g_stack.batts[ix].isIdle()){idleCnt++;}
      else{ alarmCnt++; } //should not really happen!

      if(g_stack.batteryCount == 1)
      {
        socLow = g_stack.batts[ix].soc;
        tempLow  = g_stack.batts[ix].cellTempLow;
        tempHigh = g_stack.batts[ix].cellTempHigh;
      }
      else
      {
        if(socLow > g_stack.batts[ix].soc){socLow = g_stack.batts[ix].soc;}
        if(tempHigh < g_stack.batts[ix].cellTempHigh){tempHigh = g_stack.batts[ix].cellTempHigh;}
        if(tempLow > g_stack.batts[ix].cellTempLow){tempLow = g_stack.batts[ix].cellTempLow;}
      }
      
    }
  }

  //now update stack state:
  g_stack.avgVoltage /= g_stack.batteryCount;
  g_stack.soc = socLow;

  if(tempHigh > 15000) //15C
  {
    g_stack.temp = tempHigh; //in the summer we highlight the warmest cell
  }
  else
  {
    g_stack.temp = tempLow; //in the winter we focus on coldest cell
  }

  if(alarmCnt > 0)
  {
    strcpy(g_stack.baseState, "Alarm!");
  }
  else if(chargeCnt == g_stack.batteryCount)
  {
    strcpy(g_stack.baseState, "Charge");
    g_stack.soc = (int)(socAvg / g_stack.batteryCount);
  }
  else if(dischargeCnt == g_stack.batteryCount)
  {
    strcpy(g_stack.baseState, "Dischg");
  }
  else if(idleCnt == g_stack.batteryCount)
  {
    strcpy(g_stack.baseState, "Idle");
  }
  else
  {
    strcpy(g_stack.baseState, "Balance");
  }


  return true;
}

void prepareJsonOutput(char* pBuff, int buffSize)
{
  memset(pBuff, 0, buffSize);
  snprintf(pBuff, buffSize-1, "{\"soc\": %d, \"temp\": %d, \"currentDC\": %ld, \"avgVoltage\": %ld, \"baseState\": \"%s\", \"batteryCount\": %d, \"powerDC\": %ld, \"estPowerAC\": %ld, \"isNormal\": %s}", g_stack.soc, 
                                                                                                                                                                                                            g_stack.temp, 
                                                                                                                                                                                                            g_stack.currentDC, 
                                                                                                                                                                                                            g_stack.avgVoltage, 
                                                                                                                                                                                                            g_stack.baseState, 
                                                                                                                                                                                                            g_stack.batteryCount, 
                                                                                                                                                                                                            g_stack.getPowerDC(), 
                                                                                                                                                                                                            g_stack.getEstPowerAc(),
                                                                                                                                                                                                            g_stack.isNormal() ? "true" : "false");
}

void loop() {
#ifdef ENABLE_MQTT
  mqttLoop();
#endif
  
  ArduinoOTA.handle();
  server.handleClient();

  //if there are bytes availbe on serial here - it's unexpected
  //when we send a command to battery, we read whole response
  //if we get anything here anyways - we will log it
  int bytesAv = Serial.available();
  if(bytesAv > 0)
  {
    if(bytesAv > 63)
    {
      bytesAv = 63;
    }
    
    char buff[64+4] = "RCV:";
    if(Serial.readBytes(buff+4, bytesAv) > 0)
    {
      digitalWrite(LED_BUILTIN, LOW);
      delay(5);
      digitalWrite(LED_BUILTIN, HIGH);//high is off

      Log(buff);
    }
  }
}

#ifdef ENABLE_MQTT
#define ABS_DIFF(a, b) (a > b ? a-b : b-a)
void mqtt_publish_f(const char* topic, float newValue, float oldValue, float minDiff, bool force)
{
  char szTmp[16] = "";
  snprintf(szTmp, 15, "%.2f", newValue);
  if(force || ABS_DIFF(newValue, oldValue) > minDiff)
  {
    mqttClient.publish(topic, szTmp, false);
  }
}

void mqtt_publish_i(const char* topic, int newValue, int oldValue, int minDiff, bool force)
{
  char szTmp[16] = "";
  snprintf(szTmp, 15, "%d", newValue);
  if(force || ABS_DIFF(newValue, oldValue) > minDiff)
  {
    mqttClient.publish(topic, szTmp, false);
  }
}

void mqtt_publish_s(const char* topic, const char* newValue, const char* oldValue, bool force)
{
  if(force || strcmp(newValue, oldValue) != 0)
  {
    mqttClient.publish(topic, newValue, false);
  }
}

void pushBatteryDataToMqtt(const batteryStack& lastSentData, bool forceUpdate /* if true - we will send all data regardless if it's the same */)
{
  mqtt_publish_f(MQTT_TOPIC_ROOT "soc",          g_stack.soc,                lastSentData.soc,                0, forceUpdate);
  mqtt_publish_f(MQTT_TOPIC_ROOT "temp",         (float)g_stack.temp/1000.0, (float)lastSentData.temp/1000.0, 0.1, forceUpdate);
  mqtt_publish_i(MQTT_TOPIC_ROOT "currentDC",    g_stack.currentDC,          lastSentData.currentDC,          1, forceUpdate);
  mqtt_publish_i(MQTT_TOPIC_ROOT "estPowerAC",   g_stack.getEstPowerAc(),    lastSentData.getEstPowerAc(),   10, forceUpdate);
  mqtt_publish_i(MQTT_TOPIC_ROOT "battery_count",g_stack.batteryCount,       lastSentData.batteryCount,       0, forceUpdate);
  mqtt_publish_s(MQTT_TOPIC_ROOT "base_state",   g_stack.baseState,          lastSentData.baseState            , forceUpdate);
  mqtt_publish_i(MQTT_TOPIC_ROOT "is_normal",    g_stack.isNormal() ? 1:0,   lastSentData.isNormal() ? 1:0,   0, forceUpdate);
  mqtt_publish_i(MQTT_TOPIC_ROOT "getPowerDC",   g_stack.getPowerDC(),       lastSentData.getPowerDC(),       1, forceUpdate);
  mqtt_publish_i(MQTT_TOPIC_ROOT "powerIN",      g_stack.powerIN(),          lastSentData.powerIN(),          1, forceUpdate);
  mqtt_publish_i(MQTT_TOPIC_ROOT "powerOUT",     g_stack.powerOUT(),         lastSentData.powerOUT(),         1, forceUpdate);

  // publishing details
  for (int ix = 0; ix < g_stack.batteryCount; ix++) {
    char ixBuff[50];
    String ixBattStr = MQTT_TOPIC_ROOT + String(ix) + "/voltage";
    ixBattStr.toCharArray(ixBuff, 50);
    mqtt_publish_f(ixBuff, g_stack.batts[ix].voltage / 1000.0, lastSentData.batts[ix].voltage / 1000.0, 0, forceUpdate);
    ixBattStr = MQTT_TOPIC_ROOT + String(ix) + "/current";
    ixBattStr.toCharArray(ixBuff, 50);
    mqtt_publish_f(ixBuff, g_stack.batts[ix].current / 1000.0, lastSentData.batts[ix].current / 1000.0, 0, forceUpdate);
    ixBattStr = MQTT_TOPIC_ROOT + String(ix) + "/soc";
    ixBattStr.toCharArray(ixBuff, 50);
    mqtt_publish_i(ixBuff, g_stack.batts[ix].soc, lastSentData.batts[ix].soc, 0, forceUpdate);
    ixBattStr = MQTT_TOPIC_ROOT + String(ix) + "/charging";
    ixBattStr.toCharArray(ixBuff, 50);
    mqtt_publish_i(ixBuff, g_stack.batts[ix].isCharging()?1:0, lastSentData.batts[ix].isCharging()?1:0, 0, forceUpdate);
    ixBattStr = MQTT_TOPIC_ROOT + String(ix) + "/discharging";
    ixBattStr.toCharArray(ixBuff, 50);
    mqtt_publish_i(ixBuff, g_stack.batts[ix].isDischarging()?1:0, lastSentData.batts[ix].isDischarging()?1:0, 0, forceUpdate);
    ixBattStr = MQTT_TOPIC_ROOT + String(ix) + "/idle";
    ixBattStr.toCharArray(ixBuff, 50);
    mqtt_publish_i(ixBuff, g_stack.batts[ix].isIdle()?1:0, lastSentData.batts[ix].isIdle()?1:0, 0, forceUpdate);
    ixBattStr = MQTT_TOPIC_ROOT + String(ix) + "/state";
    ixBattStr.toCharArray(ixBuff, 50);
    mqtt_publish_s(ixBuff, g_stack.batts[ix].isIdle()?"Idle":g_stack.batts[ix].isCharging()?"Charging":g_stack.batts[ix].isDischarging()?"Discharging":"", lastSentData.batts[ix].isIdle()?"Idle":lastSentData.batts[ix].isCharging()?"Charging":lastSentData.batts[ix].isDischarging()?"Discharging":"", forceUpdate);
    ixBattStr = MQTT_TOPIC_ROOT + String(ix) + "/temp";
    ixBattStr.toCharArray(ixBuff, 50);
    mqtt_publish_f(ixBuff, (float)g_stack.batts[ix].tempr/1000.0, (float)lastSentData.batts[ix].tempr/1000.0, 0.1, forceUpdate);
  }
} 

void mqttLoop()
{
  //if we have problems with connecting to mqtt server, we will attempt to re-estabish connection each 1minute (not more than that)
  static unsigned long g_lastConnectionAttempt = 0;

  //first: let's make sure we are connected to mqtt
  const char* topicLastWill = MQTT_TOPIC_ROOT "availability";
  if (!mqttClient.connected() && (g_lastConnectionAttempt == 0 || os_getCurrentTimeSec() - g_lastConnectionAttempt > 60)) {
    if(mqttClient.connect(WIFI_HOSTNAME, MQTT_USER, MQTT_PASSWORD, topicLastWill, 1, true, "offline"))
    {
      Log("Connected to MQTT server: " MQTT_SERVER);
      mqttClient.publish(topicLastWill, "online", true);
    }
    else
    {
      Log("Failed to connect to MQTT server.");
    }

    g_lastConnectionAttempt = os_getCurrentTimeSec();
  }

  //next: read data from battery and send via MQTT (but only once per MQTT_PUSH_FREQ_SEC seconds)
  static unsigned long g_lastDataSent = 0;
  if(mqttClient.connected() && 
     os_getCurrentTimeSec() - g_lastDataSent > MQTT_PUSH_FREQ_SEC &&
     sendCommandAndReadSerialResponse("pwr") == true)
  {
    static batteryStack lastSentData; //this is the last state we sent to MQTT, used to prevent sending the same data over and over again
    static unsigned int callCnt = 0;
    
    parsePwrResponse(g_szRecvBuff);

    bool forceUpdate = (callCnt % 20 == 0); //push all the data every 20th call
    pushBatteryDataToMqtt(lastSentData, forceUpdate);
    
    callCnt++;
    g_lastDataSent = os_getCurrentTimeSec();
    memcpy(&lastSentData, &g_stack, sizeof(batteryStack));
  }
  
  mqttClient.loop();
}

#endif //ENABLE_MQTT

 

Die Wärmepumpe (NEURA) in das Smarthome einbinden

Loading

Ein Smarthome ist heute keine Seltenheit mehr und sehr weit verbreitet. Es gibt unzählige Systeme am Markt, die das eigene Zuhause „Smart“ machen. Die digitalen Sprachassistenten von Google, Amazon und co. in Verbindung mit Smarten Glühlampen zählen zu den einfach und schnell zu installierenden Systemen. Aber es gibt auch komplexe Smart Home Systeme, bei denen in den Hausverteilern Aktoren für jede Lampe und Steckdosen verbaut sind. Die Fenster und Türen sind mit Meldekontakten ausgestattet und sichern das Eigenheim oder melden, wenn einmal auf das Schließen der Fenster nach dem Stoßlüften vergessen wird. Das diese Systeme bei vernünftiger Programmierung auch zur Energieoptimierung beitragen ist selbstverständlich. Auch ich betreibe Smarthome Komponenten unterschiedlichster Hersteller.

Dazu gehört seit Jahren das HomeMatic System, das sowohl kabelgebunden als auch über das Bidcos-Protokoll mit seinen Aktoren und Sensoren kommuniziert. Das HUE – System von Phillips spricht dabei über ZigBee mit seinen smarten Lampen und Steckdosen. Die Gateways dieser Systeme sind an ein LAN Netzwerk angeschlossen und jedes System bringt seinen eigenen Webserver mit, über den es dann zu steuern und einzustellen ist. Ein Wechselrichter von Photovoltaikanlagen kann seine Daten über unterschiedlichste Schnittstellen (RS485, CAN, RS232) zur Verfügung stellen. Um alle auf eine zentrale Darstellungsebene zu bringen, habe ich mich für das NodeRed System entschieden. Der Dazu notwendige NodeRed Server läuft auf einem Raspberry PI. (Auf der CCU3 mit dem Raspbian Image ist noch genug Platz um den NodeRed Server laufen zu lassen – der ist sogar als eigenes Plugin für die CCU verfügbar und wird „RedMatic“ genannt).  Mit dieser Konfiguration lässt sich fast alles im Bereich Homeautomation „erschlagen“. Mit ESP32 und Raspberry lassen sich über MQTT (Message Queueing Telemetry Transport) bequem Statusinformationen übertragen. Dies wende ich beispielsweise bei den kleinen Einspeise Wechselrichtern einer Balkon PV-Anlage an, als auch bei den PV-Wechselrichtern einer Offgrid-Anlage. Hier werden die Daten über unterschiedliche Bussysteme im Raspberry oder ESP32 empfangen und in das MQTT-Protokoll umgesetzt. Der MQTT Broker sammelt die Daten der einzelnen Geräte und über NodeRed lassen sie sich dann in eine Datenbank schreiben, im Browser oder am Smartphone visualisieren und auch einfach, je nach Bedarf, im HomeMatic System verarbeiten.

Beispiel eines Smarthomenetzwerks

Somit ist es möglich, nahezu alle Systeme miteinander Smart zu vernetzen und, für mich wichtig auf EINER Plattform zu visualisieren. Ein einziges System fehlte bisher noch. Das ist meine alte Neura Heizungswärmpepumpe. Die Firma Neura ist schon seit einigen Jahren nicht mehr existent und der von „b.i.t.“ entwickelte auf Webserver „webidalog“ wurde nie mehr aktualisiert. Die Wärmepumpe hat also einen Webserver auf einem kleinen mit Linux-Rechner onboard und baut die Webapplikation mit einer uralten Java Version. Für die Bedienung muss am PC eine Java Runtime installiert sein, die nur mit einigen Tricks auf einem aktuellen Windows Rechner läuft (Stichwort: Virtualisierung). Für die Bedienung über ein Smartphone ist eine html – Version mit eingeschränkter Funktionalität verfügbar. Mein Plan war es nun, eine Schnittstelle zu finden, mit der ich die Daten der Wärmepumpe zumindest einmal auslesen kann, um Vorlauf- Rücklauftemperaturen der Fußbodenheizung, Kesseltemperatur, etc. auch in meinem NodeRed System zur Verfügung habe. Da zu dem System aber so gut wie keine Dokumentation zu finden ist und ein Reverse-Engineering ein wenig kritisch ist, wenn das System weiter laufen soll, kam mir folgende Idee:

Mit einem „headles browser“ sollte es ja möglich sein, die html-Version der Neura WebDialog Website zu parsen und die relevanten Daten zu finden und über Variablen in MQTT-Topics zu verwandeln. Und hier muss ich einen besonderen Dank an meinen Kollegen Mario Wehr aussprechen, der mir die Softwarestrukur zum parsen der Website gebaut hat. Die Software ist in PHP geschrieben und läuft schlussendlich auf einem Raspberry PI. Hier sind lediglich eine php8-cli runtime und ein paar Module notwendig. Die Software funktioniert so, dass bei jedem Aufruf ein Login auf der Wärmepumpenwebsite ausgeführt wird, danach werden die Daten geparsed und zu MQTT-Broker gesendet. Das kontinuierliche Aufrufen des php-Skriptes habe ich dann einfach mit einem cronjob gelöst, der jede Minute ausgeführt wird.

 

>sudo crontab -e

und der job sieht dann so aus:

* * * * * sudo php /home/neura2mqtt/neura2mqtt.php -c

(wenn man sich die files  ins  /home/ verzeichnis legt…). Das Projekt habe ich auf github unter:  https://github.com/ingmarsretro/neura2mqtt veröffentlicht.

Neura Daten am NodeRed Dashboard