growatt_shine.tc¶
growatt_shine.tc — per-tracker (PV1/PV2) monitor for Growatt inverters via
// ============================================================================
// growatt_shine.tc — per-tracker (PV1/PV2) monitor for Growatt inverters via
// the Growatt Shine cloud (server.growatt.com)
// ============================================================================
//
// WHY: AC-side Modbus meters only see total inverter output — a single failed
// MPPT tracker/string just looks like "less sun". The Shine cloud has the
// per-tracker DC values (the inverter uploads them every 5 min through the
// ShineWIFI/ShineLan stick), so this script polls them and raises an alarm
// when one tracker collapses while its sibling is producing.
//
// LOGIN + API (classic JSP ShineServer — verified live 2026-07-14):
// POST /newTwoLoginAPI.do userName=<u>&password=<h>
// <h> = md5-hex of the password with every even-index '0' -> 'c'
// cookies: JSESSIONID + SERVERID; body contains the plant list
// GET /newTwoPlantAPI.do?op=getAllDeviceListTwo&plantId=<id>&pageNum=1&pageSize=10
// -> "invList":[{"deviceSn":"..","deviceType":"tlx"|"inverter",..}]
// GET /newTlxApi.do?op=getTlxDetailData&id=<sn> (type tlx)
// GET /newInverterAPI.do?op=getInverterDetailData&inverterId=<sn> (classic)
// -> both return the SAME field names:
// ppv1/ppv2 (W), vpv1/vpv2 (V), epv1Today/epv2Today (kWh), pac, status
//
// CONFIG /growatt_creds.txt (only credentials; inverters are auto-discovered):
// user: myuser
// passw: mypassword
//
// ALARM RULE: tracker B suspect when A > ALM_MIN_W while B < 5% of A for
// ALM_POLLS consecutive polls (and vice versa). Latched until B recovers to
// >20% of A. Alarm shows red on the main page + addLog line.
//
// Console: GRT status summary
// GRTP poll now
// GRTL re-login + re-discover
// ============================================================================
#define GW_HOST "server.growatt.com"
#define POLL_SECS 300 // 5 min — matches the stick's upload interval
#define ALM_MIN_W 250.0 // sibling must produce at least this much
#define ALM_POLLS 3 // consecutive polls before alarm (3 x 5min)
#define NINV_MAX 3
// ── credentials + session ───────────────────────────────────────────────────
char gw_user[32];
char gw_hash[36]; // md5-0c hash of the password (computed once)
char ck_sess[64]; // JSESSIONID=...
char ck_srv[64]; // SERVERID=...
int logged_in = 0;
int http_stat = 0; // last HTTP status (diagnostics)
// ── discovered inverters (max 3) ────────────────────────────────────────────
int ninv = 0;
char sn0[24]; char sn1[24]; char sn2[24]; // serials
char nm0[24]; char nm1[24]; char nm2[24]; // plant names
int ty0; int ty1; int ty2; // 1 = tlx, 0 = classic inverter
// ── live values per inverter ────────────────────────────────────────────────
float g_p1[NINV_MAX]; float g_p2[NINV_MAX]; // tracker power W
float g_v1[NINV_MAX]; float g_v2[NINV_MAX]; // tracker voltage V
float g_e1[NINV_MAX]; float g_e2[NINV_MAX]; // tracker kWh today
float g_pac[NINV_MAX]; // AC output W
int g_st[NINV_MAX]; // status (1 = normal)
int g_age[NINV_MAX]; // secs since last good poll
int g_alm[NINV_MAX]; // alarm bits: 1 = PV1, 2 = PV2
int g_ac1[NINV_MAX]; int g_ac2[NINV_MAX]; // consecutive-suspect counters
// ── scratch ─────────────────────────────────────────────────────────────────
// NOTE: buffers > ~100 chars MUST live at file scope — a TinyC stack frame has
// only TC_MAX_LOCALS=256 slots, and a big local char[] silently overflows it
// (runtime "Bounds error", no compile warning).
char body[6000]; // response body (tlx detail ~5.2 KB)
char lbuf[240]; // header/line scratch
char jval[48]; // extracted json value
char req[320]; // HTTP request builder (http_get / gw_login)
char post[150]; // login POST body
char gpath[140]; // request path builder
char grow[320]; // web card row builder
// Erkennungszustand. nplants = wie viele Anlagen die Anmeldung gemeldet hat,
// have[i] = fuer diese Anlage wurde ein Wechselrichter gefunden. Beides ist
// noetig, um „unvollstaendig erkannt" von „fertig" zu unterscheiden — vorher
// gab es nur ninv, und das konnte beides bedeuten.
int nplants = 0;
int have[NINV_MAX];
int redisc_tick = 0;
#define REDISC_POLLS 20 // so viele Abfragen zwischen zwei Nachfassversuchen
int next_poll = 15; // first poll shortly after boot
int poll_tick = 0;
// ── SMA Sunny Boy, per UDP von einem anderen Geraet ─────────────────────────
// Der alte SB 5000TL-20 haengt an Bluetooth Classic und kann nur von EINEM Geraet
// gleichzeitig gelesen werden — deshalb liest ihn ein eigener Knoten (die ESP32-Uhr
// im Keller, examples/sma_sunnyboy.tc) und schickt die Werte einmal je Minute als
// UDP-Multicast-Feld. Hier kommen sie nur noch an, damit alle vier Wechselrichter
// auf EINER Seite stehen.
//
// [0] Tracker A Watt [2] Tracker A Volt [4] AC-Wirkleistung Watt
// [1] Tracker B Watt [3] Tracker B Volt [5] ALTER der Messung in Sekunden
//
// ⭐ Als `global` deklariert — dann fuellt die FIRMWARE das Feld selbst, sobald ein
// Paket mit diesem Namen eintrifft. Kein udpRecvArray, kein Abholen: der Compiler
// meldet jedes `global` als udpGlobal an, und der Empfaenger kopiert die Werte direkt
// nach globals[]. Der Variablenname IST der Name auf der Leitung, muss also zum
// udpSendArray("sma", ...) der Gegenstelle passen (max. 15 Zeichen).
//
// ⚠️ Deshalb hoechstens 16 Elemente: groessere Felder wandern in den Heap, und die
// Brücke schreibt nach globals[]. Sechs passen bequem.
#define SMA_NAME "sma"
#define SMA_ALT_MAX 600 // aelter als 10 min = die Gegenstelle meldet sich nicht mehr
global float sma[6];
int sma_da = 0; // 1 = mindestens ein Paket empfangen
// ── small helpers ───────────────────────────────────────────────────────────
// copy src -> dst (sprintf-based; TinyC has no strcpy builtin)
void scopy(char dst[], char src[]) { sprintf(dst, "%s", src); }
// DESTRUCTIVE first-occurrence extractor on body[]: find "key":, copy the
// value (quotes stripped) to out, then cripple the matched key ("key"->"xey")
// so the NEXT call finds the following occurrence. This avoids offset refs
// entirely — passing a heap array + offset (body + off) as a user-function
// char[] arg bounds-crashes the VM (compiler gap, 2026-07-14).
int jtake(char key[], char out[]) {
out[0] = 0;
char pat[40];
sprintf(pat, "\"%s\":", key);
int p = strFind(body, pat);
if (p < 0) { return 0; }
int vp = p + strlen(pat);
if (body[vp] == '"') { vp = vp + 1; }
int i = 0;
while (i < 40) {
char c = body[vp + i];
if (c == 0 || c == '"' || c == ',' || c == '}') { break; }
out[i] = c;
i = i + 1;
}
out[i] = 0;
body[p + 1] = 'x';
return 1;
}
// extract a JSON value: find "key": in hay, copy value (quotes stripped) to
// out. Returns 1 if found. Works on offset refs (hay may be body+pos).
int jget(char hay[], char key[], char out[]) {
out[0] = 0;
char pat[40];
sprintf(pat, "\"%s\":", key);
int p = strFind(hay, pat);
if (p < 0) { return 0; }
p = p + strlen(pat);
if (hay[p] == '"') { p = p + 1; }
int i = 0;
while (i < 40) {
char c = hay[p + i];
if (c == 0 || c == '"' || c == ',' || c == '}') { break; }
out[i] = c;
i = i + 1;
}
out[i] = 0;
return 1;
}
// Growatt password hash: md5 hex, then every even-index '0' -> 'c'
void gw_make_hash(char pw[]) {
char dig[16];
md5(pw, strlen(pw), dig);
char hexc[17];
sprintf(hexc, "%s", "0123456789abcdef");
for (int i = 0; i < 16; i = i + 1) {
int b = dig[i] & 255;
gw_hash[i * 2] = hexc[(b >> 4) & 15];
gw_hash[i * 2 + 1] = hexc[b & 15];
}
gw_hash[32] = 0;
for (int i = 0; i < 32; i = i + 2) {
if (gw_hash[i] == '0') { gw_hash[i] = 'c'; }
}
}
int read_creds() {
if (fileExists("/growatt_creds.txt") == 0) { return 0; }
int h = fileOpen("/growatt_creds.txt", 0);
if (h < 0) { return 0; }
int n = fileRead(h, body, 200);
fileClose(h);
if (n <= 0) { return 0; }
body[n] = 0;
char pw[48];
pw[0] = 0; gw_user[0] = 0;
int li = 1;
while (strToken(lbuf, body, '\n', li) > 0) {
strTrim(lbuf);
int p = strFind(lbuf, "user:");
if (p == 0) { strSub(jval, lbuf, 5, 31); strTrim(jval); scopy(gw_user, jval); }
p = strFind(lbuf, "passw:");
if (p == 0) { strSub(jval, lbuf, 6, 41); strTrim(jval); scopy(pw, jval); }
li = li + 1;
}
if (gw_user[0] == 0 || pw[0] == 0) { return 0; }
gw_make_hash(pw);
return 1;
}
// ── HTTP over raw TLS (HTTP/1.0 + Connection: close, one connect/request) ───
// read status line + headers; capture Set-Cookie JSESSIONID/SERVERID when
// want_ck=1. Returns HTTP status code (0 on protocol error).
int http_headers(int want_ck) {
if (tlsReadLine(lbuf) <= 0) { return 0; }
strSub(jval, lbuf, 9, 3);
int st = atoi(jval);
int m = 1;
while (m > 0) {
m = tlsReadLine(lbuf);
if (m > 0 && want_ck == 1) {
int p = strFind(lbuf, "Set-Cookie: JSESSIONID=");
if (p == 0) {
int q = strFind(lbuf, ";");
if (q > 12) { strSub(ck_sess, lbuf, 12, q - 12); }
}
p = strFind(lbuf, "Set-Cookie: SERVERID=");
if (p == 0) {
int q = strFind(lbuf, ";");
if (q > 12) { strSub(ck_srv, lbuf, 12, q - 12); }
}
}
}
return st;
}
// GET path -> body[]; returns HTTP status. Sends the session cookies.
int http_get(char path[]) {
body[0] = 0;
if (tlsConnect(GW_HOST, 443) != 0) { http_stat = -1; return -1; }
sprintf(req, "GET %s HTTP/1.0\r\nHost: %s\r\nCookie: %s; %s\r\nUser-Agent: Mozilla/5.0\r\nConnection: close\r\n\r\n",
path, GW_HOST, ck_sess, ck_srv);
tlsWrite(req);
int st = http_headers(0);
tlsRead(body, 5900);
tlsStop();
http_stat = st;
return st;
}
// ── login + inverter discovery ──────────────────────────────────────────────
int gw_login() {
logged_in = 0;
ck_sess[0] = 0; ck_srv[0] = 0;
if (tlsConnect(GW_HOST, 443) != 0) { http_stat = -1; return 0; }
sprintf(post, "userName=%s&password=%s", gw_user, gw_hash);
sprintf(req, "POST /newTwoLoginAPI.do HTTP/1.0\r\nHost: %s\r\nContent-Type: application/x-www-form-urlencoded\r\nUser-Agent: Mozilla/5.0\r\nConnection: close\r\nContent-Length: %d\r\n\r\n",
GW_HOST, strlen(post));
tlsWrite(req);
tlsWrite(post);
int st = http_headers(1);
tlsRead(body, 5900); // login body carries the plant list
tlsStop();
http_stat = st;
if (st != 200) { return 0; }
if (strFind(body, "\"success\":true") < 0) { return 0; }
if (ck_sess[0] == 0) { return 0; }
logged_in = 1;
return 1;
}
// pick plant ids/names out of the login body (jtake = occurrence-ordered,
// one pair per plant object), then fetch each plant's device list and take
// the first entry whose deviceType is an inverter ("tlx" or "inverter") —
// the lists also carry meters ("other") and datalogger sticks, which we skip.
void gw_discover() {
ninv = 0;
char pid0[12]; char pid1[12]; char pid2[12];
int np = 0;
while (np < NINV_MAX) {
if (jtake("plantId", jval) == 0) { break; }
if (np == 0) { scopy(pid0, jval); }
if (np == 1) { scopy(pid1, jval); }
if (np == 2) { scopy(pid2, jval); }
jtake("plantName", jval);
if (np == 0) { scopy(nm0, jval); }
if (np == 1) { scopy(nm1, jval); }
if (np == 2) { scopy(nm2, jval); }
np = np + 1;
}
nplants = np;
addLog("GRT: %d plants", np);
// per plant: device list -> first real inverter entry
for (int i = 0; i < np; i = i + 1) {
char pid[12];
if (i == 0) { scopy(pid, pid0); }
if (i == 1) { scopy(pid, pid1); }
if (i == 2) { scopy(pid, pid2); }
sprintf(gpath, "/newTwoPlantAPI.do?op=getAllDeviceListTwo&plantId=%s&pageNum=1&pageSize=10", pid);
have[i] = 0;
// ZWEITER VERSUCH. Genau hier ging die Garage verloren: ein einzelner
// fehlgeschlagener Abruf liess die Anlage still ausfallen.
int ok = 0;
int tries = 0;
while (tries < 2) {
if (http_get(gpath) == 200) { ok = 1; tries = 9; }
else { delay(700); tries = tries + 1; }
}
if (ok == 0) {
addLog("GRT: Geraeteliste Anlage %d nicht abrufbar — fehlt vorerst", i);
continue;
}
int found = 0;
while (found == 0) {
if (jtake("deviceSn", jval) == 0) { break; }
char dty[16];
if (jtake("deviceType", dty) == 0) { break; }
int t = -1;
if (strFind(dty, "tlx") == 0) { t = 1; }
if (strFind(dty, "inverter") == 0) { t = 0; }
if (t >= 0) {
if (i == 0) { scopy(sn0, jval); ty0 = t; }
if (i == 1) { scopy(sn1, jval); ty1 = t; }
if (i == 2) { scopy(sn2, jval); ty2 = t; }
have[i] = 1;
found = 1;
char nmx[24];
if (i == 0) { scopy(nmx, nm0); }
if (i == 1) { scopy(nmx, nm1); }
if (i == 2) { scopy(nmx, nm2); }
addLog("GRT: found %s type=%d (%s)", jval, t, nmx);
}
}
if (found == 0) { addLog("GRT: Anlage %d ohne Wechselrichter-Eintrag", i); }
}
// ninv bildet ab jetzt die ANLAGEN ab, nicht die Erfolge. Vorher stand hier
// ninv = i + 1 aus der Schleife — faellt Anlage 0 aus und 1 und 2 gelingen,
// ergab das ninv=3 mit einer leeren Seriennummer auf Platz 0.
ninv = np;
}
// Wie viele Anlagen noch ohne Wechselrichter sind.
int disc_missing() {
int m = 0;
for (int i = 0; i < nplants; i = i + 1) {
if (have[i] == 0) { m = m + 1; }
}
return m;
}
// ── per-inverter poll + alarm rule ──────────────────────────────────────────
void gw_poll_one(int i) {
if (have[i] == 0) { return; } // Anlage ohne erkannten Wechselrichter
char sn[24];
int ty = 0;
if (i == 0) { scopy(sn, sn0); ty = ty0; }
if (i == 1) { scopy(sn, sn1); ty = ty1; }
if (i == 2) { scopy(sn, sn2); ty = ty2; }
if (ty == 1) { sprintf(gpath, "/newTlxApi.do?op=getTlxDetailData&id=%s", sn); }
else { sprintf(gpath, "/newInverterAPI.do?op=getInverterDetailData&inverterId=%s", sn); }
if (http_get(gpath) != 200) { logged_in = 0; return; }
if (strFind(body, "\"ppv1\":") < 0) {
// session expired (HTML login page instead of JSON) -> re-login next tick
logged_in = 0;
return;
}
if (jget(body, "ppv1", jval)) { g_p1[i] = atof(jval); }
if (jget(body, "ppv2", jval)) { g_p2[i] = atof(jval); }
if (jget(body, "vpv1", jval)) { g_v1[i] = atof(jval); }
if (jget(body, "vpv2", jval)) { g_v2[i] = atof(jval); }
if (jget(body, "epv1Today", jval)) { g_e1[i] = atof(jval); }
if (jget(body, "epv2Today", jval)) { g_e2[i] = atof(jval); }
if (jget(body, "pac", jval)) { g_pac[i] = atof(jval); }
if (jget(body, "status", jval)) { g_st[i] = atoi(jval); }
g_age[i] = 0;
// tracker-failure rule: sibling produces, this one collapsed
float a = g_p1[i];
float b = g_p2[i];
if (a > ALM_MIN_W && b < a * 0.05) { g_ac2[i] = g_ac2[i] + 1; } else {
if (b > a * 0.2) { g_ac2[i] = 0; g_alm[i] = g_alm[i] & 1; } // clear PV2 alarm
}
if (b > ALM_MIN_W && a < b * 0.05) { g_ac1[i] = g_ac1[i] + 1; } else {
if (a > b * 0.2) { g_ac1[i] = 0; g_alm[i] = g_alm[i] & 2; } // clear PV1 alarm
}
if (g_ac2[i] >= ALM_POLLS && (g_alm[i] & 2) == 0) {
g_alm[i] = g_alm[i] | 2;
addLog("GRT ALARM: inverter %d PV2 dead (PV1=%.0fW PV2=%.0fW)", i, a, b);
}
if (g_ac1[i] >= ALM_POLLS && (g_alm[i] & 1) == 0) {
g_alm[i] = g_alm[i] | 1;
addLog("GRT ALARM: inverter %d PV1 dead (PV1=%.0fW PV2=%.0fW)", i, a, b);
}
}
void gw_poll_all() {
if (logged_in == 0) {
if (gw_login() == 0) { return; }
if (nplants == 0) { gw_discover(); }
}
// NACHFASSEN, solange eine Anlage ohne Wechselrichter dasteht.
// Vorher lief die Erkennung nur bei ninv == 0 — ein einmal verlorener
// Wechselrichter kam deshalb NIE von selbst zurueck, auch nicht nach einer
// neuen Anmeldung; es half nur GRTL von Hand. Genau so ist die Garage
// wochenlang verschwunden geblieben.
if (disc_missing() > 0) {
redisc_tick = redisc_tick + 1;
if (redisc_tick >= REDISC_POLLS) {
redisc_tick = 0;
addLog("GRT: %d Anlage(n) unvollstaendig — Erkennung wird wiederholt",
disc_missing());
gw_discover();
}
} else {
redisc_tick = 0;
}
for (int i = 0; i < ninv; i = i + 1) {
gw_poll_one(i);
delay(300); // be gentle with the server
}
}
// ── main loop ───────────────────────────────────────────────────────────────
void TaskLoop() {
delay(1000);
// Die WERTE kommen von selbst — hier wird nur vermerkt, DASS je eines ankam.
// ⚠️ Ohne diese Unterscheidung waere ein nie eingetroffenes Paket nicht von einer
// echten Null zu trennen: globals[] startet auf 0, und "PV1 0W" liest sich wie
// „erzeugt gerade nichts" statt „Gegenstelle schweigt". udpReady() traegt ein
// Frische-Bit je Name und raeumt es beim Lesen ab.
if (udpReady(SMA_NAME)) { sma_da = 1; }
if (tasm_net == 0) { return; } // no net op before link-up!
for (int i = 0; i < ninv; i = i + 1) { g_age[i] = g_age[i] + 1; }
poll_tick = poll_tick + 1;
if (poll_tick < next_poll) { return; }
poll_tick = 0;
next_poll = POLL_SECS;
gw_poll_all();
}
// ── console ─────────────────────────────────────────────────────────────────
void Command(char cmd[]) {
if (cmd[0] == 'P') { // GRTP — poll now
poll_tick = next_poll;
responseCmnd("GRT: poll scheduled");
}
else if (cmd[0] == 'L') { // GRTL — fresh login + discover
logged_in = 0;
ninv = 0;
poll_tick = next_poll;
responseCmnd("GRT: re-login scheduled");
}
else { // GRT — summary
char r[220];
sprintf(r, "login=%d http=%d ninv=%d | 0: %.0f/%.0fW a=%d | 1: %.0f/%.0fW a=%d | 2: %.0f/%.0fW a=%d",
logged_in, http_stat, ninv,
g_p1[0], g_p2[0], g_alm[0],
g_p1[1], g_p2[1], g_alm[1],
g_p1[2], g_p2[2], g_alm[2]);
sprintfAppend(r, " | SMA: %.0f/%.0fW alt=%.0fs", sma[0], sma[1], sma[5]);
responseCmnd(r);
}
}
// ── main-page card ──────────────────────────────────────────────────────────
void web_row(int i) {
char nm[24];
if (i == 0) { scopy(nm, nm0); }
if (i == 1) { scopy(nm, nm1); }
if (i == 2) { scopy(nm, nm2); }
// Eine Anlage ohne erkannten Wechselrichter NICHT mit Nullen darstellen —
// das sieht aus wie „liefert gerade nichts" statt „wurde nicht gefunden".
if (have[i] == 0) {
sprintf(grow, "{s}%s{m}<span style='color:#e0a030'>nicht erkannt — Erkennung laeuft erneut</span>{e}", nm);
webSend(grow);
return;
}
char c1[16]; char c2[16];
// NB: a string ternary as a USER-function char[] arg bounds-crashes the VM
// (works only for builtin string args) -> plain if/else here.
if (g_alm[i] & 1) { scopy(c1, "#e74c3c"); } else { scopy(c1, "#9fd39f"); }
if (g_alm[i] & 2) { scopy(c2, "#e74c3c"); } else { scopy(c2, "#9fd39f"); }
sprintf(grow, "{s}%s{m}<span style='color:%s'>PV1 %.0fW %.0fV %.1fkWh</span> <span style='color:%s'>PV2 %.0fW %.0fV %.1fkWh</span>{e}",
nm, c1, g_p1[i], g_v1[i], g_e1[i], c2, g_p2[i], g_v2[i], g_e2[i]);
webSend(grow);
if (g_alm[i] != 0) {
char which[4];
if (g_alm[i] & 1) { scopy(which, "1"); } else { scopy(which, "2"); }
sprintf(grow, "{s} {m}<b style='color:#e74c3c'>TRACKER-AUSFALL PV%s</b>{e}", which);
webSend(grow);
}
}
// Der SMA als vierte Zeile, im selben Aufbau wie die Growatt-Zeilen.
// ⚠️ Kein kWh-Wert: der Wechselrichter liefert ueber SMAdata2+ Leistung und Spannung,
// die Tagesarbeit holen wir nicht ab. Lieber weglassen als eine Null hinschreiben,
// die wie „nichts erzeugt" aussieht.
void sma_row() {
if (sma_da == 0) {
webSend("{s}SMA Sunny Boy{m}<span style='color:#e0a030'>noch kein Wert empfangen</span>{e}");
return;
}
if (sma[5] < 0.0 || sma[5] > SMA_ALT_MAX) {
// Das Alter kommt vom Sender mit; -1 heisst „noch nie erfolgreich gemessen".
sprintf(grow, "{s}SMA Sunny Boy{m}<span style='color:#e0a030'>veraltet (%.0f s)</span>{e}", sma[5]);
webSend(grow);
return;
}
sprintf(grow, "{s}SMA Sunny Boy{m}<span style='color:#9fd39f'>PV1 %.0fW %.0fV</span> <span style='color:#9fd39f'>PV2 %.0fW %.0fV</span> AC %.0fW{e}",
sma[0], sma[2], sma[1], sma[3], sma[4]);
webSend(grow);
}
void WebCall() {
webSend("{s}<hr>{m}<hr>{e}{s}<b>Growatt Tracker</b>{m}");
char hd[110];
char st[52];
if (logged_in == 1) { scopy(st, "online"); } else { scopy(st, "<span style='color:#e74c3c'>offline</span>"); }
int mins = g_age[0] / 60;
sprintf(hd, "%s vor %d min{e}", st, mins);
webSend(hd);
for (int i = 0; i < ninv; i = i + 1) { web_row(i); }
sma_row();
}
int main() {
if (read_creds() == 0) {
addLog("GRT: /growatt_creds.txt missing or bad (user:/passw: lines)");
return 1;
}
addCommand("GRT");
addLog("GRT: growatt_shine ready (user %s)", gw_user);
return 0;
}