Community project

Goodwe Smartmeter Modbus Monitor

Michael Bilic

Published August 15, 2026

ESP32
Photo of Goodwe Smartmeter Modbus MonitorGenerated with AI

This project turns an ESP32 into a Modbus monitor for GoodWe solar inverters, reading real-time meter data via RS485 communication. The system captures consumption, production, voltage, current, frequency, and power readings from the GM300 smartmeter, then exposes this data through a web dashboard and REST API.

The guide provides a complete wiring diagram connecting the TTL-to-RS485 adapter and relay modules to the ESP32, a full parts list, pre-configured firmware with WiFi and web server capabilities, and step-by-step assembly instructions. Builders will have a networked energy monitoring station ready to integrate with home automation systems or display on local dashboards.

Wiring diagram

Interactive · read-only
Wiring diagram for Goodwe Smartmeter Modbus Monitor

Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.

Parts list

Bill of materials
ComponentQtyNotes
AYWHP TTL-zu-RS485-Modul, 3,3 V, automatische Richtungsumschaltung3,3 V1Voll-duplex UART-zu-halbdulpex RS485-Transceivermodul mit VCC, GND, TXD und RXD auf der TTL-Seite sowie automatischer Sende-/Empfangsumschaltung; keine separate DE-/RE-Steuerleitung.
Future Relay Module 1 (3.3 V logic input)Reserved GPIO211One preassembled relay module to be installed later. Its logic input must reliably accept a 3.3 V ESP32 signal; relay contacts are deliberately not part of this low-voltage monitor design.
Future Relay Module 2 (3.3 V logic input)Reserved GPIO471One preassembled relay module to be installed later. Its logic input must reliably accept a 3.3 V ESP32 signal; relay contacts are deliberately not part of this low-voltage monitor design.

Assembly

4 steps
  1. ESP32 und RS485-Adapter stromlos verdrahten

    Trenne den ESP32 vom USB. Verbinde den 3,3-V-RS485-Adapter mit 3V3 des ESP32, GND mit GND, TXD des Adapters mit GPIO17 und RXD des Adapters mit GPIO18. Der Adapter schaltet die RS485-Richtung automatisch um.

    • Tip: Die Anschlüsse TXD/RXD beziehen sich bei diesem Adapter auf die ESP/UART-Seite; sie werden hier nicht gekreuzt.
    • Tip: Verwende für VCC ausschließlich 3,3 V.
    • Keine 5 V an einen ESP32-GPIO oder an den 3,3-V-RS485-Adapter legen.
  2. RS485 mit dem GoodWe GM300 verbinden

    Verbinde A des RS485-Adapters mit A beziehungsweise D+ des GM300 und B mit B beziehungsweise D−. Die 230-V-Seite ist nicht Bestandteil dieses Projekts und wird nicht verändert.

    • Tip: Bei dauerhaft fehlender Modbus-Antwort zuerst A und B miteinander tauschen.
    • Tip: Die Firmware liest ausschließlich Modbus-Funktion 03 und schreibt keine Zählerparameter.
    • Nur an der Kleinspannungs-/Kommunikationsschnittstelle arbeiten.
  3. Zukünftige Relais vorbereiten

    Für spätere, fertig aufgebaute Relaismodule mit sicher 3,3-V-kompatiblem Logikeingang ist IN von Relais 1 für GPIO21 und IN von Relais 2 für GPIO47 reserviert. Beide Module benötigen eine gemeinsame Masse mit dem ESP32. Ihre Versorgung folgt der Kennzeichnung des später gekauften Moduls; im Plan ist die übliche 5-V-Relaisversorgung vorgesehen.

    • Tip: Nachrüsten erst, wenn du ein Relaismodul mit 3,3-V-tauglichem IN-Eingang hast.
    • Tip: Die API schaltet beide Ausgänge beim Neustart immer aus.
    • Relais-Spulen niemals direkt an GPIO21 oder GPIO47 anschließen.
    • Netzspannung und Relaiskontakte nur mit einem geeigneten, isolierten Fertigmodul und fachgerechter Installation verwenden.
  4. Onboard-Status-LED prüfen

    Die WS2812-RGB-LED des Sixspan-Boards nutzt GPIO48. Nach dem Start blinkt sie rot bei fehlender GM300-Kommunikation, orange bei eingerichteten aber nicht erreichbaren Router-WLAN und leuchtet grün, wenn Modbus und Router-Verbindung in Ordnung sind. Wenn sie nicht leuchtet, prüfe den kleinen RGB-Lötjumper auf deiner Boardvariante.

    • Tip: Ohne eingerichtetes Router-WLAN bedeutet eine erfolgreiche Modbus-Verbindung ebenfalls grün.
    • Tip: Der Access Point des ESP bleibt unter 192.168.4.1 erreichbar.
    • Den GPIO48-Pin nicht extern beschalten; er gehört zur Onboard-LED.

Pin assignments

Board wiring reference
PinConnectionType
3V3rs485_1 VCCpower
GNDrs485_1 GNDground
GPIO 17rs485_1 TXDuart
GPIO 18rs485_1 RXDuart
EXTrs485_1 AGoodWe GM300 RS485 A / D+data
EXTrs485_1 BGoodWe GM300 RS485 B / D-data
GPIO 21relay_1_future INdigital
GNDrelay_1_future GNDground
GPIO 47relay_2_future INdigital
GNDrelay_2_future GNDground
5Vrelay_1_future VCCpower
5Vrelay_2_future VCCpower

Firmware

ESP32
main.cppDeploy to device
#include <Arduino.h>
#include <WiFi.h>
#include <WebServer.h>
#include <Preferences.h>
#include <Adafruit_NeoPixel.h>


struct Config {
  String apSsid, apPass, wifiSsid, wifiPass, ip, gw, mask, dns, token;
  bool dhcp;
  uint32_t baud;
  uint8_t slave;
} cfg;

struct Meter {
  bool ok = false;
  String error = "Noch keine Abfrage";
  uint32_t updateMs = 0;
  float con = NAN, prod = NAN, reacE = NAN, capE = NAN;
  float v[3] = {NAN, NAN, NAN};
  float a[3] = {NAN, NAN, NAN};
  float hz = NAN;
  int16_t p[3] = {0, 0, 0};
  int16_t pt = 0, rp = 0, ap = 0;
} meter;


// Forward declarations
uint16_t crc16(const uint8_t *data, size_t length);
String numberText(float value, int decimals);
String jsonEscape(String value);
String htmlEscape(String value);
bool parseIp(const String &text, IPAddress &ip);
void loadConfig();
void saveConfig();
void startNetwork();
bool readHoldingRegisters(uint16_t start, uint16_t count, uint16_t *output, String &error);
float registersToFloat(uint16_t high, uint16_t low);
void pollMeter();
void updateStatusLed();
void sendStatusApi();
void sendRelaysApi();
void setRelayApi();
void scanWifiApi();
void settingsPage();
void saveSettings();
void dashboardPage();

static const int RS485_TX_PIN = 17;
static const int RS485_RX_PIN = 18;
static const int RELAY_1_PIN = 21;
static const int RELAY_2_PIN = 47;
static const int STATUS_LED_PIN = 48;
static const bool RELAY_ACTIVE_HIGH = true;
static const uint32_t POLL_MS = 5000;





WebServer server(80);
Preferences prefs;
HardwareSerial rs485(1);
Adafruit_NeoPixel statusPixel(1, STATUS_LED_PIN, NEO_GRB + NEO_KHZ800);
bool relayState[2] = {false, false};
uint32_t lastLedUpdate = 0;

uint16_t crc16(const uint8_t *data, size_t length) {
  uint16_t crc = 0xFFFF;
  while (length--) {
    crc ^= *data++;
    for (uint8_t bit = 0; bit < 8; bit++) crc = (crc & 1) ? (crc >> 1) ^ 0xA001 : crc >> 1;
  }
  return crc;
}

String numberText(float value, int decimals) {
  if (isnan(value)) return "null";
  char buffer[24];
  snprintf(buffer, sizeof(buffer), "%.*f", decimals, (double)value);
  return String(buffer);
}

String jsonEscape(String value) {
  value.replace("\\", "\\\\");
  value.replace("\"", "\\\"");
  value.replace("\n", " ");
  value.replace("\r", " ");
  return value;
}

String htmlEscape(String value) {
  value.replace("&", "&amp;");
  value.replace("\"", "&quot;");
  value.replace("<", "&lt;");
  value.replace(">", "&gt;");
  return value;
}

bool parseIp(const String &text, IPAddress &ip) { return ip.fromString(text); }

void loadConfig() {
  prefs.begin("gm300", true);
  uint64_t mac = ESP.getEfuseMac();
  char defaultSsid[24];
  char defaultToken[17];
  snprintf(defaultSsid, sizeof(defaultSsid), "GM300-Setup-%04X", (uint16_t)mac);
  snprintf(defaultToken, sizeof(defaultToken), "%08lX%08lX", (uint32_t)(mac >> 32), (uint32_t)mac);
  cfg.apSsid = prefs.getString("apssid", defaultSsid);
  cfg.apPass = prefs.getString("appass", "");
  cfg.wifiSsid = prefs.getString("wifissid", "");
  cfg.wifiPass = prefs.getString("wifipass", "");
  cfg.dhcp = prefs.getBool("dhcp", true);
  cfg.ip = prefs.getString("ip", "");
  cfg.gw = prefs.getString("gw", "");
  cfg.mask = prefs.getString("mask", "255.255.255.0");
  cfg.dns = prefs.getString("dns", "");
  cfg.baud = prefs.getUInt("baud", 9600);
  cfg.slave = prefs.getUChar("slave", 3);
  cfg.token = prefs.getString("token", defaultToken); // Kept only for backward-compatible stored settings; no API token is required.
  prefs.end();
}

void saveConfig() {
  prefs.begin("gm300", false);
  prefs.putString("apssid", cfg.apSsid); prefs.putString("appass", cfg.apPass);
  prefs.putString("wifissid", cfg.wifiSsid); prefs.putString("wifipass", cfg.wifiPass);
  prefs.putBool("dhcp", cfg.dhcp); prefs.putString("ip", cfg.ip); prefs.putString("gw", cfg.gw);
  prefs.putString("mask", cfg.mask); prefs.putString("dns", cfg.dns);
  prefs.putUInt("baud", cfg.baud); prefs.putUChar("slave", cfg.slave);
  prefs.end();
}

void startNetwork() {
  WiFi.mode(WIFI_AP_STA);
  WiFi.softAPConfig(IPAddress(192, 168, 4, 1), IPAddress(192, 168, 4, 1), IPAddress(255, 255, 255, 0));
  if (cfg.apPass.length() >= 8) WiFi.softAP(cfg.apSsid.c_str(), cfg.apPass.c_str());
  else WiFi.softAP(cfg.apSsid.c_str());

  if (!cfg.wifiSsid.length()) return;
  if (!cfg.dhcp) {
    IPAddress ip, gateway, mask, dns;
    if (parseIp(cfg.ip, ip) && parseIp(cfg.gw, gateway) && parseIp(cfg.mask, mask)) {
      if (!parseIp(cfg.dns, dns)) dns = gateway;
      WiFi.config(ip, gateway, mask, dns);
    }
  }
  WiFi.begin(cfg.wifiSsid.c_str(), cfg.wifiPass.c_str());
}

bool readHoldingRegisters(uint16_t start, uint16_t count, uint16_t *output, String &error) {
  if (!count || count > 30) { error = "Ungültige Registeranzahl"; return false; }
  uint8_t request[8] = {cfg.slave, 3, (uint8_t)(start >> 8), (uint8_t)start, (uint8_t)(count >> 8), (uint8_t)count, 0, 0};
  uint16_t requestCrc = crc16(request, 6);
  request[6] = requestCrc & 0xFF;
  request[7] = requestCrc >> 8;

  while (rs485.available()) rs485.read();
  rs485.write(request, sizeof(request));
  rs485.flush();

  uint8_t response[65];
  const size_t expected = 5 + count * 2;
  size_t received = 0;
  uint32_t began = millis();
  while (received < expected && millis() - began < 1000) {
    if (rs485.available()) response[received++] = rs485.read();
    else delay(1);
  }
  if (received < 5) { error = "Keine Modbus-Antwort (A/B, Adresse oder Einstellungen prüfen)"; return false; }
  if (response[0] != cfg.slave || response[1] != 3 || response[2] != count * 2 || received != expected) {
    error = "Ungültige oder unvollständige Modbus-Antwort"; return false;
  }
  uint16_t receivedCrc = (uint16_t)response[received - 2] | ((uint16_t)response[received - 1] << 8);
  if (crc16(response, received - 2) != receivedCrc) { error = "CRC-Fehler auf der RS485-Leitung"; return false; }
  for (uint16_t i = 0; i < count; i++) output[i] = ((uint16_t)response[3 + i * 2] << 8) | response[4 + i * 2];
  return true;
}

float registersToFloat(uint16_t high, uint16_t low) {
  uint32_t raw = ((uint32_t)high << 16) | low;
  float result;
  memcpy(&result, &raw, sizeof(result));
  return result;
}

void pollMeter() {
  String error;
  uint16_t energy[8], live[23];
  if (!readHoldingRegisters(71, 8, energy, error) || !readHoldingRegisters(98, 23, live, error)) {
    meter.ok = false; meter.error = error; return;
  }
  meter.con = registersToFloat(energy[0], energy[1]);
  meter.prod = registersToFloat(energy[2], energy[3]);
  meter.reacE = registersToFloat(energy[4], energy[5]);
  meter.capE = registersToFloat(energy[6], energy[7]);
  for (int i = 0; i < 3; i++) {
    meter.v[i] = live[i] / 10.0f;
    meter.a[i] = live[3 + i] / 100.0f;
    meter.p[i] = (int16_t)live[6 + i];
  }
  meter.pt = (int16_t)live[9];
  meter.rp = (int16_t)live[10];
  meter.ap = (int16_t)live[14];
  meter.hz = live[22] / 100.0f;
  meter.ok = true; meter.error = "OK"; meter.updateMs = millis();
}

void updateStatusLed() {
  uint32_t now = millis();
  bool routerOk = cfg.wifiSsid.length() > 0 && WiFi.status() == WL_CONNECTED;
  uint32_t color;
  if (!meter.ok) color = ((now / 450) % 2) ? statusPixel.Color(48, 0, 0) : 0;
  else if (cfg.wifiSsid.length() > 0 && !routerOk) color = ((now / 650) % 2) ? statusPixel.Color(45, 18, 0) : 0;
  else color = statusPixel.Color(0, 42, 0);
  if (now - lastLedUpdate >= 100) {
    lastLedUpdate = now;
    statusPixel.setPixelColor(0, color);
    statusPixel.show();
  }
}

void sendStatusApi() {
  String json = "{\"online\":" + String(meter.ok ? "true" : "false") +
    ",\"message\":\"" + jsonEscape(meter.error) + "\",\"ageSeconds\":" +
    String(meter.updateMs ? (millis() - meter.updateMs) / 1000 : 0) +
    ",\"routerConnected\":" + String(WiFi.status() == WL_CONNECTED ? "true" : "false") +
    ",\"routerIp\":\"" + WiFi.localIP().toString() + "\",\"totalPower\":" + String(meter.pt) +
    ",\"consumptionWh\":" + numberText(meter.con, 1) + ",\"productionWh\":" + numberText(meter.prod, 1) +
    ",\"frequencyHz\":" + numberText(meter.hz, 2) + ",\"reactivePower\":" + String(meter.rp) +
    ",\"apparentPower\":" + String(meter.ap) + ",\"reactiveEnergyVarh\":" + numberText(meter.reacE, 1) +
    ",\"capacitiveEnergyVarh\":" + numberText(meter.capE, 1) + ",\"relays\":[" +
    String(relayState[0] ? "true" : "false") + "," + String(relayState[1] ? "true" : "false") + "],\"phases\":[";
  for (int i = 0; i < 3; i++) {
    if (i) json += ',';
    json += "{\"voltage\":" + numberText(meter.v[i], 1) + ",\"current\":" + numberText(meter.a[i], 2) + ",\"power\":" + String(meter.p[i]) + "}";
  }
  server.sendHeader("Cache-Control", "no-store");
  server.send(200, "application/json", json + "]}");
}

void sendRelaysApi() {
  server.sendHeader("Cache-Control", "no-store");
  server.send(200, "application/json", "{\"relay1\":" + String(relayState[0] ? "true" : "false") + ",\"relay2\":" + String(relayState[1] ? "true" : "false") + "}");
}

void setRelayApi() {
  int relay = server.arg("relay").toInt();
  String state = server.arg("state");
  if (relay < 1 || relay > 2 || (state != "on" && state != "off")) {
    server.send(400, "application/json", "{\"error\":\"use relay=1|2 and state=on|off\"}"); return;
  }
  relayState[relay - 1] = state == "on";
  int pin = relay == 1 ? RELAY_1_PIN : RELAY_2_PIN;
  digitalWrite(pin, relayState[relay - 1] == RELAY_ACTIVE_HIGH ? HIGH : LOW);
  server.send(200, "application/json", "{\"ok\":true,\"relay\":" + String(relay) + ",\"state\":" + String(relayState[relay - 1] ? "true" : "false") + "}");
}

void scanWifiApi() {
  int found = WiFi.scanNetworks(false, true);
  String json = "{\"networks\":[";
  for (int i = 0; i < found; i++) {
    if (i) json += ',';
    json += "{\"ssid\":\"" + jsonEscape(WiFi.SSID(i)) + "\",\"rssi\":" + String(WiFi.RSSI(i)) + ",\"secure\":" + String(WiFi.encryptionType(i) == WIFI_AUTH_OPEN ? "false" : "true") + "}";
  }
  json += "]}";
  WiFi.scanDelete();
  server.sendHeader("Cache-Control", "no-store");
  server.send(200, "application/json", json);
}

void settingsPage() {
  String page = R"HTML(<!doctype html><html lang="de"><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>GM300 Einstellungen</title><style>
:root{--bg:#09111d;--panel:#101e31;--line:#2a425e;--text:#edf6ff;--muted:#9eb3c9;--blue:#57a8ff;--green:#36d99b}*{box-sizing:border-box}body{max-width:900px;margin:0 auto;padding:26px 18px 54px;background:var(--bg);color:var(--text);font:15px system-ui}a{color:#87c5ff;text-decoration:none}.back{display:inline-block;margin-bottom:20px}.intro{color:var(--muted);line-height:1.55}fieldset{border:1px solid var(--line);background:var(--panel);border-radius:14px;margin:17px 0;padding:18px}legend{padding:0 8px;color:#cfe3fb;font-weight:700}label{display:block;margin:13px 0 5px;color:#c1d2e4}input{width:100%;padding:12px;border-radius:9px;border:1px solid #45627f;background:#091625;color:white;font:16px system-ui}.check{width:auto;vertical-align:middle;margin-right:8px}.hint{margin:7px 0;color:var(--muted);font-size:13px;line-height:1.5}button{margin-top:8px;padding:13px 19px;border:0;border-radius:9px;background:var(--blue);color:#04101d;font-weight:750;font-size:16px}code{color:#b4d8ff}</style></head><body>
<a class="back" href="/">← Zur Übersicht</a><h1>Einstellungen</h1><p class="intro">Der eigene Access Point bleibt immer unter <b>http://192.168.4.1</b> erreichbar. Leere Passwortfelder ändern vorhandene Passwörter nicht.</p>
<form method="post" action="/save">
<fieldset><legend>ESP Access Point</legend><label>WLAN-Name (SSID)</label><input required name="as" maxlength="31" value=")HTML" + htmlEscape(cfg.apSsid) + R"HTML("><label>Neues AP-Passwort</label><input name="ap" type="password" minlength="8" autocomplete="new-password" placeholder="Mindestens 8 Zeichen; leer = unverändert"><p class="hint">Dieses Passwort schützt das WLAN, mit dem du dich direkt zum ESP verbindest.</p></fieldset>
<fieldset><legend>Router-WLAN</legend><label>WLAN-Name des Routers</label><div style="display:flex;gap:8px"><input id="routerSsid" name="ws" maxlength="31" value=")HTML" + htmlEscape(cfg.wifiSsid) + R"HTML("><button type="button" onclick="scanWifi()" style="margin:0;white-space:nowrap">WLAN suchen</button></div><div id="wifiList" class="hint">Über „WLAN suchen“ werden sichtbare Netzwerke angezeigt.</div><label>Neues Router-WLAN-Passwort</label><input name="wp" type="password" autocomplete="new-password" placeholder="leer = unverändert"><label><input class="check" type="checkbox" name="dh" value="1" )HTML" + String(cfg.dhcp ? "checked" : "") + R"HTML(>IP automatisch vom Router beziehen (DHCP)</label><label>Feste IP</label><input name="ip" inputmode="decimal" value=")HTML" + htmlEscape(cfg.ip) + R"HTML("><label>Gateway / Router-IP</label><input name="gw" inputmode="decimal" value=")HTML" + htmlEscape(cfg.gw) + R"HTML("><label>Subnetzmaske</label><input name="ma" inputmode="decimal" value=")HTML" + htmlEscape(cfg.mask) + R"HTML("><label>DNS-Server (optional)</label><input name="dn" inputmode="decimal" value=")HTML" + htmlEscape(cfg.dns) + R"HTML("></fieldset>
<fieldset><legend>GoodWe GM300 / Modbus</legend><label>Modbus-Adresse</label><input name="sl" type="number" min="1" max="247" value=")HTML" + String(cfg.slave) + R"HTML("><label>Baudrate</label><input name="ba" type="number" min="1200" max="115200" value=")HTML" + String(cfg.baud) + R"HTML("></fieldset>
<fieldset><legend>Raspberry-Pi API</legend><p class="hint">Im lokalen Netz ist kein API-Schlüssel erforderlich, wie bei einem ungeschützten Shelly. Messwerte: <code>/api/status</code>. Relais lesen: <code>/api/relays</code>. Relais schalten: <code>/api/relay?relay=1&amp;state=on</code> bzw. <code>state=off</code>.</p><p class="hint"><b>Wichtig:</b> Gib den ESP nicht ins Internet frei. Jeder, der dein Heimnetz oder ESP-Access-Point-WLAN nutzen darf, kann die Relais schalten.</p></fieldset>
<button type="submit">Speichern und neu starten</button></form><script>async function scanWifi(){const list=document.getElementById('wifiList');list.textContent='Suche WLANs …';try{const d=await (await fetch('/api/wifi-scan',{cache:'no-store'})).json();if(!d.networks.length){list.textContent='Keine sichtbaren WLANs gefunden.';return}list.innerHTML=d.networks.map(n=>'<button type="button" style="margin:6px 6px 0 0;padding:8px 10px;font-size:13px" onclick="document.getElementById(\'routerSsid\').value=this.dataset.ssid" data-ssid="'+n.ssid.replace(/&/g,'&amp;').replace(/\"/g,'&quot;')+'">'+n.ssid+(n.secure?' 🔒':'')+' ('+n.rssi+' dBm)</button>').join('')}catch(e){list.textContent='WLAN-Suche fehlgeschlagen. Bitte erneut versuchen.'}}</script></body></html>)HTML";
  server.send(200, "text/html; charset=utf-8", page);
}

void saveSettings() {
  String newApPass = server.arg("ap"), newWifiPass = server.arg("wp");
  uint8_t slave = server.arg("sl").toInt();
  uint32_t baud = server.arg("ba").toInt();
  if (!server.arg("as").length() || slave < 1 || !baud || (newApPass.length() && newApPass.length() < 8)) {
    server.send(400, "text/plain", "Ungültige Eingabe"); return;
  }
  bool dhcp = server.hasArg("dh");
  if (!dhcp) {
    IPAddress ip, gateway, mask;
    if (!parseIp(server.arg("ip"), ip) || !parseIp(server.arg("gw"), gateway) || !parseIp(server.arg("ma"), mask)) {
      server.send(400, "text/plain", "Bei fester IP sind IP, Gateway und Subnetzmaske erforderlich."); return;
    }
  }
  cfg.apSsid = server.arg("as"); if (newApPass.length()) cfg.apPass = newApPass;
  cfg.wifiSsid = server.arg("ws"); if (newWifiPass.length()) cfg.wifiPass = newWifiPass;
  cfg.dhcp = dhcp; cfg.ip = server.arg("ip"); cfg.gw = server.arg("gw"); cfg.mask = server.arg("ma"); cfg.dns = server.arg("dn");
  cfg.slave = slave; cfg.baud = baud;
  saveConfig();
  server.send(200, "text/html", "<!doctype html><meta name=viewport content='width=device-width,initial-scale=1'><body style='font:18px system-ui;background:#09111d;color:#eef6ff;padding:32px'><h2>Gespeichert</h2><p>Der ESP startet jetzt neu. Verbinde dich danach gegebenenfalls mit dem neuen AP-Namen.</p></body>");
  delay(900); ESP.restart();
}

void dashboardPage() {
  static const char page[] PROGMEM = R"HTML(<!doctype html><html lang="de"><head><meta name="viewport" content="width=device-width,initial-scale=1"><title>GM300 Energie-Monitor</title><style>
:root{--bg:#07111e;--side:#0c1929;--card:#111f32;--card2:#142842;--line:#28415e;--text:#eff7ff;--muted:#9eb5ce;--blue:#6ab1ff;--green:#37dc9b;--orange:#ffad45;--red:#ff6475}*{box-sizing:border-box;scroll-behavior:smooth}body{margin:0;background:radial-gradient(750px 420px at 78% -5%,#183d68,transparent 70%),var(--bg);color:var(--text);font:15px system-ui}.sidebar{position:fixed;z-index:5;width:242px;height:100vh;padding:25px 15px;background:rgba(10,23,39,.96);border-right:1px solid var(--line)}.brand{padding:0 12px 24px;font-size:18px;font-weight:750}.brand small{display:block;margin-top:5px;color:var(--muted);font-size:12px;font-weight:400}.nav a{display:block;padding:11px 13px;margin:4px 0;border-radius:9px;color:#b8cde1;text-decoration:none}.nav a:hover,.nav a.active{color:white;background:#173354}.sideInfo{position:absolute;bottom:22px;left:27px;right:20px;color:var(--muted);font-size:12px;line-height:1.6}.sideInfo b{color:#d7eaff}.main{margin-left:242px;max-width:1320px;padding:28px 30px 54px}.top{display:flex;align-items:start;justify-content:space-between;gap:18px;margin-bottom:24px}h1{font-size:26px;margin:0 0 5px}.sub{color:var(--muted);line-height:1.5}.badge{padding:8px 11px;border:1px solid var(--line);border-radius:99px;font-size:13px;white-space:nowrap}.badge.ok{color:var(--green)}.badge.bad{color:var(--red)}.grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}.phases{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.card{background:linear-gradient(145deg,var(--card2),var(--card));border:1px solid var(--line);border-radius:15px;padding:17px;box-shadow:0 12px 28px #00000012}.label{color:var(--muted);font-size:13px}.value{margin:10px 0 2px;font-size:30px;font-weight:750;letter-spacing:-1px}.unit{color:var(--muted);font-size:13px}h2{margin:32px 0 12px;font-size:13px;letter-spacing:1.2px;color:#bdd3e8}.phasePower{font-size:24px;font-weight:700;margin:11px 0 14px}.detail{padding-top:11px;border-top:1px solid var(--line);color:#c7d7e7;font-size:14px}.relayGrid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}.relay{display:flex;align-items:center;justify-content:space-between;gap:12px}.switch{position:relative;width:52px;height:30px;flex:none}.switch input{opacity:0;width:0;height:0}.slider{position:absolute;inset:0;background:#52677e;border-radius:30px;cursor:pointer;transition:.2s}.slider:before{content:'';position:absolute;width:22px;height:22px;left:4px;top:4px;border-radius:50%;background:#fff;transition:.2s}.switch input:checked+.slider{background:var(--green)}.switch input:checked+.slider:before{transform:translateX(22px)}.switch input:disabled+.slider{opacity:.42;cursor:not-allowed}.tokenBox{display:flex;gap:8px;margin:0 0 14px}.tokenBox input{min-width:0;flex:1;padding:10px;border:1px solid var(--line);border-radius:8px;background:#091726;color:white}.tokenBox button{padding:10px 13px;border:0;border-radius:8px;background:#427fb9;color:white}.note{color:var(--muted);font-size:13px;line-height:1.5}.toast{position:fixed;right:22px;bottom:20px;background:#1b3049;border:1px solid var(--line);padding:11px 14px;border-radius:9px;opacity:0;transform:translateY(8px);transition:.2s}.toast.show{opacity:1;transform:none}@media(max-width:820px){.sidebar{position:static;width:auto;height:auto;padding:16px;border-right:0;border-bottom:1px solid var(--line)}.brand{padding:0 7px 10px}.nav{display:flex;overflow:auto;gap:4px}.nav a{white-space:nowrap}.sideInfo{display:none}.main{margin:0;padding:22px 16px 42px}.grid{grid-template-columns:repeat(2,minmax(0,1fr))}.phases,.relayGrid{grid-template-columns:1fr}.value{font-size:25px}}@media(max-width:390px){.grid{grid-template-columns:1fr}.top{display:block}.badge{display:inline-block;margin-top:12px}}</style></head><body>
<aside class="sidebar"><div class="brand">GM300 Monitor<small>Lokales Energie-Dashboard</small></div><nav class="nav"><a class="active" href="#overview">▣ Übersicht</a><a href="#phases">◫ Phasen</a><a href="#relays">⏻ Relais</a><a href="#details">⌁ Messwerte</a><a href="/settings">⚙ Einstellungen</a></nav><div class="sideInfo"><b>Access Point</b><br>192.168.4.1<br><br><b>RS485 / Modbus</b><br>Nur lesender Zugriff</div></aside>
<main class="main"><div class="top" id="overview"><div><h1>Energie im Blick</h1><div class="sub">GoodWe GM300 · Abfrage alle 5 Sekunden</div></div><div id="status" class="badge">● Verbinde …</div></div>
<div class="grid"><section class="card"><div class="label">Gesamtwirkleistung</div><div id="pt" class="value">—</div><div class="unit">Watt</div></section><section class="card"><div class="label">Energie Bezug</div><div id="con" class="value">—</div><div class="unit">kWh</div></section><section class="card"><div class="label">Energie Erzeugung</div><div id="prod" class="value">—</div><div class="unit">kWh</div></section><section class="card"><div class="label">Netzfrequenz</div><div id="hz" class="value">—</div><div class="unit">Hertz</div></section></div>
<h2 id="phases">PHASEN</h2><div id="phaseCards" class="phases"></div>
<h2 id="relays">RELAIS – VORBEREITETE AUSGÄNGE</h2><section class="card"><div class="tokenBox"><input id="token" type="password" placeholder="API-Schlüssel für Relais eingeben"><button onclick="saveToken()">Im Browser merken</button></div><div class="note">Der Schlüssel wird nur in diesem Browser gespeichert und nie auf der Seite angezeigt. Beide Ausgänge starten nach einem ESP-Neustart immer ausgeschaltet.</div></section><div class="relayGrid" style="margin-top:14px"><section class="card relay"><div><div class="label">Relais 1</div><div class="phasePower"><span id="r1txt">AUS</span></div><div class="note">Vorbereiteter Steuerpin: <b>GPIO 21</b></div></div><label class="switch"><input id="r1" type="checkbox" onchange="toggleRelay(1,this)"><span class="slider"></span></label></section><section class="card relay"><div><div class="label">Relais 2</div><div class="phasePower"><span id="r2txt">AUS</span></div><div class="note">Vorbereiteter Steuerpin: <b>GPIO 47</b></div></div><label class="switch"><input id="r2" type="checkbox" onchange="toggleRelay(2,this)"><span class="slider"></span></label></section></div>
<h2 id="details">WEITERE MESSWERTE</h2><div class="grid"><section class="card"><div class="label">Blindleistung</div><div id="rp" class="value">—</div><div class="unit">VAr</div></section><section class="card"><div class="label">Scheinleistung</div><div id="ap" class="value">—</div><div class="unit">VA</div></section><section class="card"><div class="label">Blindenergie</div><div id="re" class="value">—</div><div class="unit">VArh</div></section><section class="card"><div class="label">Kapazitive Energie</div><div id="ce" class="value">—</div><div class="unit">VArh</div></section></div><p id="age" class="note" style="margin-top:22px"></p></main><div id="toast" class="toast"></div>
<script>const $=id=>document.getElementById(id),fmt=(v,d=1)=>v===null||v===undefined?'—':Number(v).toLocaleString('de-DE',{minimumFractionDigits:d,maximumFractionDigits:d});function toast(t){let e=$('toast');e.textContent=t;e.classList.add('show');setTimeout(()=>e.classList.remove('show'),2800)}function saveToken(){sessionStorage.setItem('gm300token',$('token').value);toast('Schlüssel nur in diesem Browser gespeichert')}function setRelayUi(i,on){$('r'+i).checked=on;$('r'+i+'txt').textContent=on?'EIN':'AUS'}async function toggleRelay(i,box){let token=sessionStorage.getItem('gm300token')||$('token').value;if(!token){box.checked=!box.checked;toast('Bitte zuerst den API-Schlüssel eingeben');return}try{let url='/api/relay?token='+encodeURIComponent(token)+'&relay='+i+'&state='+(box.checked?'on':'off');let r=await fetch(url,{cache:'no-store'});if(!r.ok)throw Error();let d=await r.json();setRelayUi(i,d.state)}catch(e){box.checked=!box.checked;toast('Relais konnte nicht geschaltet werden – API-Schlüssel prüfen')}}async function refresh(){try{let d=await(await fetch('/api/status',{cache:'no-store'})).json(),online=d.online;let b=$('status');b.textContent=online?'● GM300 online':'● Keine Zählerantwort';b.className='badge '+(online?'ok':'bad');$('age').textContent=online?'Letzte gültige Zählerantwort vor '+d.ageSeconds+' Sekunden.':'Modbus prüfen: '+d.message;$('pt').textContent=fmt(d.totalPower,0);$('con').textContent=fmt(d.consumptionWh===null?null:d.consumptionWh/1000,3);$('prod').textContent=fmt(d.productionWh===null?null:d.productionWh/1000,3);$('hz').textContent=fmt(d.frequencyHz,2);$('rp').textContent=fmt(d.reactivePower,0);$('ap').textContent=fmt(d.apparentPower,0);$('re').textContent=fmt(d.reactiveEnergyVarh,1);$('ce').textContent=fmt(d.capacitiveEnergyVarh,1);$('phaseCards').innerHTML=d.phases.map((p,i)=>'<section class="card"><div class="label">Phase L'+(i+1)+'</div><div class="phasePower">'+fmt(p.power,0)+' W</div><div class="detail">'+fmt(p.voltage,1)+' V &nbsp;·&nbsp; '+fmt(p.current,2)+' A</div></section>').join('');setRelayUi(1,d.relays[0]);setRelayUi(2,d.relays[1])}catch(e){$('status').textContent='● Webserver nicht erreichbar';$('status').className='badge bad'}}$('token').value=sessionStorage.getItem('gm300token')||'';refresh();setInterval(refresh,5000)</script></body></html>)HTML";
  server.send_P(200, "text/html; charset=utf-8", page);
}

void setup() {
  Serial.begin(115200);
  loadConfig();
  pinMode(RELAY_1_PIN, OUTPUT); pinMode(RELAY_2_PIN, OUTPUT);
  digitalWrite(RELAY_1_PIN, RELAY_ACTIVE_HIGH ? LOW : HIGH);
  digitalWrite(RELAY_2_PIN, RELAY_ACTIVE_HIGH ? LOW : HIGH);
  statusPixel.begin(); statusPixel.clear(); statusPixel.show();
  rs485.begin(cfg.baud, SERIAL_8N1, RS485_RX_PIN, RS485_TX_PIN);
  startNetwork();
  server.on("/", dashboardPage);
  server.on("/settings", settingsPage);
  server.on("/save", HTTP_POST, saveSettings);
  server.on("/api/status", sendStatusApi);
  server.on("/api/relays", sendRelaysApi);
  server.on("/api/relay", setRelayApi);
  server.on("/api/wifi-scan", scanWifiApi);
  server.begin();
}

void loop() {
  server.handleClient();
  static uint32_t lastPoll = 0;
  if (millis() - lastPoll >= POLL_MS) { lastPoll = millis(); pollMeter(); }
  updateStatusLed();
}

“Deploy to device” opens this project in Schematik, where you can flash it to your board over USB.

Remix this project

Make it yours in one click

Open a full copy of this project in your own Schematik workspace — diagram, code, parts, and assembly steps included. Swap the sensor, add features, or redesign the whole thing with AI. The author's original stays untouched.

Open in Schematik