Skip to content

growatt_shine.tc

growatt_shine.tc — per-tracker (PV1/PV2) monitor for Growatt inverters via

Source on GitHub

// ============================================================================
// 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> &nbsp; <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}&nbsp;{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> &nbsp; <span style='color:#9fd39f'>PV2 %.0fW %.0fV</span> &nbsp; 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 &nbsp; 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;
}