/* * Fronius → Shelly Pro 3EM Emulator v2.1 * Optimierungen: * - WiFi Modem-Sleep deaktiviert (Fix für Ping-Drops!) * - WiFi persistent(false) + setAutoReconnect * - Statische IP (optional, empfohlen) * - StaticJsonDocument statt DynamicJsonDocument (kein Heap-Fragmentation) * - Alle JSON-Builder direkt in char-Buffer (keine String-Heap-Fragmentation) * - Non-blocking WiFi-Reconnect (kein delay(5000) blockiert Loop) * - HTTP-Timeout reduziert * - yield() in blockierenden Schleifen */ #include #include #include #include #include #include // ============================================================ // *** KONFIGURATION *** // ============================================================ const char* WIFI_SSID = "WLAN"; const char* WIFI_PASSWORD = ""; // Statische IP (verhindert DHCP-Verzögerungen bei Reconnect) // Auf 0,0,0,0 setzen um DHCP zu nutzen IPAddress STATIC_IP(192, 168, 2, 200); IPAddress GATEWAY (192, 168, 2, 1); IPAddress SUBNET (255, 255, 255, 0); IPAddress DNS1 (192, 168, 2, 1); const char* FRONIUS_URL = "http://FroNIUS_IP/solar_api/v1/GetMeterRealtimeData.cgi" "?Scope=Device&DeviceId=0&DataCollection=MeterRealtimeData"; const char* SHELLY_MAC = "18fe34d39d0a"; const int UDP_LISTEN_PORT = 1010; const int MARSTEK_UDP_PORT= 22222; const int HTTP_PORT = 80; const unsigned long UPDATE_MS = 10000; const float GRID_VOLTAGE = 230.0f; // ============================================================ // Globale Variablen // ============================================================ WiFiUDP udp; WiFiServer httpServer(HTTP_PORT); float g_powerSum = 0.0f; float g_powerL1 = 0.0f; float g_powerL2 = 0.0f; float g_powerL3 = 0.0f; double g_energyConsumed = 0.0; double g_energyProduced = 0.0; unsigned long g_lastUpdate = 0; unsigned long g_lastReconnect = 0; bool g_dataValid = false; IPAddress g_marstekIP; bool g_marstekKnown = false; // Shelly-IDs als char-Array (kein String-Overhead) char g_shellyId[40]; // Wiederverwendeter HTTP-Client (weniger Heap-Allokationen) WiFiClient g_wifiClient; // ============================================================ // Hilfsfunktionen // ============================================================ // Inline-Formatierung – kein String-Objekt, direkt in Zielbuffer inline void fmtPower(char* dst, size_t dstLen, float val) { snprintf(dst, dstLen, "%.2f", val); } // MAC formatiert (cached, da konstant) static char g_macDisplay[18] = {0}; void initMacDisplay() { const char* m = SHELLY_MAC; snprintf(g_macDisplay, sizeof(g_macDisplay), "%c%c:%c%c:%c%c:%c%c:%c%c:%c%c", toupper(m[0]), toupper(m[1]), toupper(m[2]), toupper(m[3]), toupper(m[4]), toupper(m[5]), toupper(m[6]), toupper(m[7]), toupper(m[8]), toupper(m[9]), toupper(m[10]), toupper(m[11]) ); } // ============================================================ // WiFi-Setup und Reconnect (non-blocking) // ============================================================ void wifiSetup() { WiFi.persistent(false); // Kein Flash-Write bei jedem Connect WiFi.setAutoReconnect(true); WiFi.mode(WIFI_STA); WiFi.setSleepMode(WIFI_NONE_SLEEP); // *** FIX: Ping-Drops durch Modem-Sleep *** WiFi.hostname(g_shellyId); if (STATIC_IP[0] != 0) { WiFi.config(STATIC_IP, GATEWAY, SUBNET, DNS1); } WiFi.begin(WIFI_SSID, WIFI_PASSWORD); } // Non-blocking Reconnect: kein delay() im Loop void checkWiFi() { if (WiFi.status() == WL_CONNECTED) return; unsigned long now = millis(); if (now - g_lastReconnect < 10000UL) return; // max. alle 10s retry g_lastReconnect = now; Serial.println(F("[WiFi] Getrennt – reconnecte...")); WiFi.disconnect(false); WiFi.begin(WIFI_SSID, WIFI_PASSWORD); } // ============================================================ // Fronius API // ============================================================ bool fetchFroniusData() { if (WiFi.status() != WL_CONNECTED) return false; HTTPClient http; http.begin(g_wifiClient, FRONIUS_URL); http.setTimeout(3000); // reduziert von 5000ms http.setReuse(true); // TCP Keep-Alive int code = http.GET(); if (code != HTTP_CODE_OK) { Serial.printf_P(PSTR("[Fronius] HTTP %d\n"), code); http.end(); return false; } // StaticJsonDocument: kein Heap-Fragmentation StaticJsonDocument<2048> doc; DeserializationError err = deserializeJson(doc, http.getStream()); http.end(); if (err) { Serial.printf_P(PSTR("[Fronius] JSON-Fehler: %s\n"), err.c_str()); return false; } JsonObject data = doc[F("Body")][F("Data")]; if (data.isNull()) { Serial.println(F("[Fronius] Body.Data fehlt")); return false; } g_powerSum = data[F("PowerReal_P_Sum")] | 0.0f; g_powerL1 = data[F("PowerReal_P_Phase_1")] | 0.0f; g_powerL2 = data[F("PowerReal_P_Phase_2")] | 0.0f; g_powerL3 = data[F("PowerReal_P_Phase_3")] | 0.0f; g_energyConsumed = data[F("EnergyReal_WAC_Sum_Consumed")].as(); g_energyProduced = data[F("EnergyReal_WAC_Sum_Produced")].as(); Serial.printf_P(PSTR("[Fronius] P=%.1fW L1=%.1f L2=%.1f L3=%.1f\n"), g_powerSum, g_powerL1, g_powerL2, g_powerL3); return true; } // ============================================================ // JSON-Bausteine (direkt in char-Buffer, keine String-Objekte) // ============================================================ // Gemeinsamer EM-Status-Block int buildEMStatusJson(char* buf, size_t len) { char pS[12], pL1[12], pL2[12], pL3[12]; fmtPower(pS, sizeof(pS), g_powerSum); fmtPower(pL1, sizeof(pL1), g_powerL1); fmtPower(pL2, sizeof(pL2), g_powerL2); fmtPower(pL3, sizeof(pL3), g_powerL3); float iL1 = fabsf(g_powerL1) / GRID_VOLTAGE; float iL2 = fabsf(g_powerL2) / GRID_VOLTAGE; float iL3 = fabsf(g_powerL3) / GRID_VOLTAGE; return snprintf(buf, len, "\"id\":0," "\"a_current\":%.3f,\"a_voltage\":%.1f," "\"a_act_power\":%s,\"a_aprt_power\":%.2f,\"a_pf\":1.00,\"a_freq\":50.0," "\"b_current\":%.3f,\"b_voltage\":%.1f," "\"b_act_power\":%s,\"b_aprt_power\":%.2f,\"b_pf\":1.00,\"b_freq\":50.0," "\"c_current\":%.3f,\"c_voltage\":%.1f," "\"c_act_power\":%s,\"c_aprt_power\":%.2f,\"c_pf\":1.00,\"c_freq\":50.0," "\"total_current\":%.3f," "\"total_act_power\":%s," "\"total_aprt_power\":%.2f," "\"user_calibrated_phase\":[]", iL1, GRID_VOLTAGE, pL1, fabsf(g_powerL1), iL2, GRID_VOLTAGE, pL2, fabsf(g_powerL2), iL3, GRID_VOLTAGE, pL3, fabsf(g_powerL3), iL1 + iL2 + iL3, pS, fabsf(g_powerL1) + fabsf(g_powerL2) + fabsf(g_powerL3) ); } // Alle Antworten direkt in einen gemeinsamen Output-Buffer schreiben // Spart Stack und Heap gegenüber String-Rückgaben static char g_outBuf[2200]; // Einmaliger globaler Buffer (kein Stack-Stress) void fillEMGetStatus() { char em[900]; buildEMStatusJson(em, sizeof(em)); snprintf(g_outBuf, sizeof(g_outBuf), "{%s}", em); } void fillEMGetConfig() { snprintf(g_outBuf, sizeof(g_outBuf), "{\"id\":0,\"name\":null," "\"blink_mode_selector\":\"active_energy\"," "\"phase_selector\":\"a\"," "\"monitor_phase_sequence\":true," "\"ct_orientation\":\"connected_to_a\"}"); } void fillEMDataGetStatus() { double pc = g_energyConsumed / 3.0, pp = g_energyProduced / 3.0; snprintf(g_outBuf, sizeof(g_outBuf), "{\"id\":0," "\"a_total_act_energy\":%.2f,\"a_total_act_ret_energy\":%.2f," "\"b_total_act_energy\":%.2f,\"b_total_act_ret_energy\":%.2f," "\"c_total_act_energy\":%.2f,\"c_total_act_ret_energy\":%.2f," "\"total_act\":%.2f,\"total_act_ret\":%.2f}", pc, pp, pc, pp, pc, pp, g_energyConsumed, g_energyProduced); } void fillDeviceInfo() { snprintf(g_outBuf, sizeof(g_outBuf), "{\"name\":\"Shelly Pro 3EM\"," "\"id\":\"%s\"," "\"mac\":\"%s\"," "\"model\":\"SPEM-003CEBEU\"," "\"gen\":2," "\"fw_id\":\"20230913-114244/v1.4.2-gc2516f0\"," "\"ver\":\"1.4.2\"," "\"app\":\"Pro3EM\"," "\"auth_en\":false," "\"auth_domain\":null}", g_shellyId, g_macDisplay); } void fillShellyGetStatus() { char em[900]; buildEMStatusJson(em, sizeof(em)); double pc = g_energyConsumed / 3.0, pp = g_energyProduced / 3.0; snprintf(g_outBuf, sizeof(g_outBuf), "{\"ble\":{}," "\"cloud\":{\"connected\":false}," "\"mqtt\":{\"connected\":false}," "\"em:0\":{%s}," "\"emdata:0\":{" "\"id\":0," "\"a_total_act_energy\":%.2f,\"a_total_act_ret_energy\":%.2f," "\"b_total_act_energy\":%.2f,\"b_total_act_ret_energy\":%.2f," "\"c_total_act_energy\":%.2f,\"c_total_act_ret_energy\":%.2f," "\"total_act\":%.2f,\"total_act_ret\":%.2f}," "\"sys\":{\"available_updates\":{}," "\"uptime\":%lu,\"ram_free\":%u}," "\"wifi\":{\"rssi\":%d,\"ssid\":\"%s\",\"source\":\"sta\"}}", em, pc, pp, pc, pp, pc, pp, g_energyConsumed, g_energyProduced, millis() / 1000UL, (unsigned)ESP.getFreeHeap(), // echter RAM-Wert WiFi.RSSI(), WIFI_SSID); } // UDP-Antwort-Wrapper direkt in zweiten Buffer static char g_udpBuf[2300]; void wrapUDPResponse(int reqId) { snprintf(g_udpBuf, sizeof(g_udpBuf), "{\"id\":%d,\"src\":\"%s\",\"result\":%s}", reqId, g_shellyId, g_outBuf); } // ============================================================ // NotifyStatus Push // ============================================================ void pushNotifyStatus() { if (!g_marstekKnown) return; char em[900]; buildEMStatusJson(em, sizeof(em)); double pc = g_energyConsumed / 3.0, pp = g_energyProduced / 3.0; snprintf(g_udpBuf, sizeof(g_udpBuf), "{\"src\":\"%s\"," "\"dst\":\"all\"," "\"method\":\"NotifyStatus\"," "\"params\":{" "\"ts\":%.3f," "\"em:0\":{%s}," "\"emdata:0\":{" "\"id\":0," "\"a_total_act_energy\":%.2f,\"a_total_act_ret_energy\":%.2f," "\"b_total_act_energy\":%.2f,\"b_total_act_ret_energy\":%.2f," "\"c_total_act_energy\":%.2f,\"c_total_act_ret_energy\":%.2f," "\"total_act\":%.2f,\"total_act_ret\":%.2f}}}", g_shellyId, (float)(millis() / 1000UL), em, pc, pp, pc, pp, pc, pp, g_energyConsumed, g_energyProduced); udp.beginPacket(g_marstekIP, MARSTEK_UDP_PORT); udp.write((const uint8_t*)g_udpBuf, strlen(g_udpBuf)); udp.endPacket(); Serial.printf_P(PSTR("[PUSH] → %s:%d (%d B)\n"), g_marstekIP.toString().c_str(), MARSTEK_UDP_PORT, (int)strlen(g_udpBuf)); } // ============================================================ // UDP-Handler // ============================================================ // Einfacher String-Vergleich im Buffer (kein strstr-Overhead bei vielen Methoden) inline bool hasMeth(const char* buf, const char* meth) { return strstr(buf, meth) != nullptr; } void handleUDP() { int packetSize = udp.parsePacket(); if (packetSize <= 0) return; char inBuf[512]; int len = udp.read(inBuf, sizeof(inBuf) - 1); if (len <= 0) return; inBuf[len] = '\0'; IPAddress senderIP = udp.remoteIP(); Serial.printf_P(PSTR("[UDP] %s → %.80s\n"), senderIP.toString().c_str(), inBuf); if (!g_marstekKnown) { g_marstekIP = senderIP; g_marstekKnown = true; Serial.printf_P(PSTR("[UDP] Marstek: %s\n"), senderIP.toString().c_str()); } int reqId = 0; const char* idPtr = strstr(inBuf, "\"id\":"); if (idPtr) reqId = atoi(idPtr + 5); if (hasMeth(inBuf, "EM.GetStatus")) fillEMGetStatus(); else if (hasMeth(inBuf, "EM.GetConfig")) fillEMGetConfig(); else if (hasMeth(inBuf, "EMData.GetStatus")) fillEMDataGetStatus(); else if (hasMeth(inBuf, "Shelly.GetDeviceInfo")) fillDeviceInfo(); else if (hasMeth(inBuf, "Shelly.GetStatus")) fillShellyGetStatus(); else return; // unbekannte Methode – keine Antwort wrapUDPResponse(reqId); udp.beginPacket(senderIP, MARSTEK_UDP_PORT); udp.write((const uint8_t*)g_udpBuf, strlen(g_udpBuf)); udp.endPacket(); Serial.printf_P(PSTR("[UDP] → %s:%d (%d B)\n"), senderIP.toString().c_str(), MARSTEK_UDP_PORT, (int)strlen(g_udpBuf)); } // ============================================================ // HTTP-Handler // ============================================================ void handleHTTP() { WiFiClient client = httpServer.available(); if (!client) return; unsigned long t = millis(); while (!client.available()) { if (millis() - t > 1500) { client.stop(); return; } yield(); // WiFi-Stack weiter bedienen statt delay() } // Nur erste Zeile lesen (Request-Line) char reqLine[128]; size_t i = 0; while (client.available() && i < sizeof(reqLine) - 1) { char c = client.read(); if (c == '\r') break; reqLine[i++] = c; } reqLine[i] = '\0'; // restliche Header konsumieren while (client.available()) { String line = client.readStringUntil('\n'); if (line == "\r") break; } Serial.printf_P(PSTR("[HTTP] %s\n"), reqLine); const char* mime = "application/json"; if (strstr(reqLine, "/rpc/EM.GetStatus")) fillEMGetStatus(); else if (strstr(reqLine, "/rpc/EM.GetConfig")) fillEMGetConfig(); else if (strstr(reqLine, "/rpc/EMData.GetStatus")) fillEMDataGetStatus(); else if (strstr(reqLine, "/rpc/Shelly.GetDeviceInfo")) fillDeviceInfo(); else if (strstr(reqLine, "/rpc/Shelly.GetStatus")) fillShellyGetStatus(); else if (strstr(reqLine, "/status")) { snprintf(g_outBuf, sizeof(g_outBuf), "" "Fronius→Shelly" "

Fronius → Shelly Pro 3EM v2.1

" "" "" "" "" "" "" "" "" "" "" "" "" "
ID%s
MAC%s
IP%s
P Gesamt%.2f W
P L1/L2/L3%.1f / %.1f / %.1f W
Verbrauch%.0f Wh
Einspeisung%.0f Wh
Marstek%s (%s)
Free Heap%u Bytes
Uptime%lu s
RSSI%d dBm
", g_shellyId, g_macDisplay, WiFi.localIP().toString().c_str(), g_powerSum, g_powerL1, g_powerL2, g_powerL3, g_energyConsumed, g_energyProduced, g_marstekKnown ? "ja" : "nein", g_marstekKnown ? g_marstekIP.toString().c_str() : "-", (unsigned)ESP.getFreeHeap(), millis() / 1000UL, WiFi.RSSI() ); mime = "text/html"; } else { fillDeviceInfo(); } client.printf_P( PSTR("HTTP/1.1 200 OK\r\nContent-Type: %s\r\n" "Connection: close\r\nContent-Length: %d\r\n\r\n"), mime, (int)strlen(g_outBuf) ); client.print(g_outBuf); client.flush(); client.stop(); } // ============================================================ // Setup // ============================================================ void setup() { Serial.begin(115200); delay(200); snprintf(g_shellyId, sizeof(g_shellyId), "shellypro3em-%s", SHELLY_MAC); initMacDisplay(); Serial.printf_P(PSTR("\n=== Fronius→Shelly v2.1 | ID: %s ===\n"), g_shellyId); wifiSetup(); Serial.printf_P(PSTR("[WiFi] Verbinde mit \"%s\""), WIFI_SSID); int tries = 0; while (WiFi.status() != WL_CONNECTED) { delay(500); Serial.print('.'); if (++tries > 40) { Serial.println(F("\n[WiFi] Timeout!")); ESP.restart(); } } Serial.printf_P(PSTR("\n[WiFi] IP: %s RSSI: %d dBm Heap: %u\n"), WiFi.localIP().toString().c_str(), WiFi.RSSI(), ESP.getFreeHeap()); udp.begin(UDP_LISTEN_PORT); httpServer.begin(); Serial.printf_P(PSTR("[UDP] Port %d → Marstek Port %d\n"), UDP_LISTEN_PORT, MARSTEK_UDP_PORT); Serial.printf_P(PSTR("[HTTP] http://%s/status\n"), WiFi.localIP().toString().c_str()); if (fetchFroniusData()) { g_dataValid = true; g_lastUpdate = millis(); } } // ============================================================ // Loop // ============================================================ void loop() { checkWiFi(); // non-blocking, kein delay() if (WiFi.status() != WL_CONNECTED) return; unsigned long now = millis(); if (now - g_lastUpdate >= UPDATE_MS) { if (fetchFroniusData()) { g_dataValid = true; g_lastUpdate = now; pushNotifyStatus(); } else { g_lastUpdate = now - UPDATE_MS + 2000UL; } } handleUDP(); handleHTTP(); yield(); }