Community project
Goodwe Smartmeter Modbus Monitor
Generated with AIThis 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
Pan and zoom to explore the wiring. Remix the project to edit it in your own workspace.
Parts list
Bill of materials| Component | Qty | Notes |
|---|---|---|
| AYWHP TTL-zu-RS485-Modul, 3,3 V, automatische Richtungsumschaltung3,3 V | 1 | Voll-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 GPIO21 | 1 | One 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 GPIO47 | 1 | One 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 stepsESP32 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.
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.
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.
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| Pin | Connection | Type |
|---|---|---|
| 3V3 | rs485_1 VCC | power |
| GND | rs485_1 GND | ground |
| GPIO 17 | rs485_1 TXD | uart |
| GPIO 18 | rs485_1 RXD | uart |
| EXT | rs485_1 A → GoodWe GM300 RS485 A / D+ | data |
| EXT | rs485_1 B → GoodWe GM300 RS485 B / D- | data |
| GPIO 21 | relay_1_future IN | digital |
| GND | relay_1_future GND | ground |
| GPIO 47 | relay_2_future IN | digital |
| GND | relay_2_future GND | ground |
| 5V | relay_1_future VCC | power |
| 5V | relay_2_future VCC | power |
Firmware
ESP32#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("&", "&");
value.replace("\"", """);
value.replace("<", "<");
value.replace(">", ">");
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&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,'&').replace(/\"/g,'"')+'">'+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 · '+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.