Zum Inhalt

solar_dashboard.tc

solar_dashboard.tc — REAL energy dashboard on LVGL (P4), built on the gchart primitives.

Source on GitHub

// solar_dashboard.tc — REAL energy dashboard on LVGL (P4), built on the gchart primitives.
//
//   CHART 1: solar power of 4 inverters (4 time series, ONE shared y-axis), 24 h / 5 min
//   CHART 2: Powerwall SOC (%), 24 h / 5 min
//   GAUGE  : live SUM of the 4 inverters (kW), real-time
//
// Data comes from UDP-published Scripter global vars (read-only consume):
//   sedc=Dach, wrgh=Gartenhaus, wrga=Garage, wrgg=Garten, pwl=Powerwall SOC.
// History is a rolling 24 h ring buffer (288 x 5 min) sampled live every 5 min.
// SEED fills a synthetic day so the charts show immediately — set SEED 0 for pure-real.
// Tune SOLAR_MAX_W to your biggest inverter; values assumed in WATTS.

// ---- live globals (read-only consume of existing UDP vars) ----
global float sedc;   // Dach
global float wrgh;   // Gartenhaus
global float wrga;   // Garage
global float wrgg;   // Garten
global float pwl;    // Powerwall SOC %
global float msoc;   // Marstek Venus E SOC % (broadcast by marstek_venus.tc on .170)

#define SEED         1            // 1 = pre-fill a synthetic day (demo); 0 = real-accumulate only
#define NBINS        288          // 24 h / 5 min
#define SOLAR_MAX_W  5000         // y-axis full scale (W) -> labelled 0..5 kW
#define GAUGE_MAXT   100          // gauge full scale in tenths of kW (100 = 10.0 kW)
#define SAMPLE_SEC   300          // history sample interval (s) = 5 min (24 h / 288 bins)
#define SOLBIN_SIZE  5776         // exact valid size: 4-int hdr + 5 arrays x 288 x 4B (power-safe check)

#define ST_RADIUS    120
#define ST_BORDER     56
#define ST_BCOLOR     57
#define ST_BOPA       58
#define AL_CENTER      9
#define PI         3.14159265

#define C_BG     0x000000
#define C_CARD   0xFFFFFF
#define C_CARDB  0xDADCE0
#define C_GRID   0xBDC1C6
#define C_BASE   0x9AA0A6
#define C_PLOT   0xF1F3F4
#define C_AXIS   0x5F6368
#define C_TITLE  0x202124
#define C_BLUE   0x4285F4
#define C_GREEN  0x34A853
#define C_AMBER  0xFBBC04
#define C_RED    0xEA4335

// solar plot rect / powerwall plot rect
#define SX 100
#define SY 110
#define SW 600
#define SH 330
#define PX 100
#define PY 582
#define PW 600
#define PH 196

int px[300]; int py[300];                  // polyline scratch
int wSolar[4][288];                        // 4 inverter series x 288 bins (heap, 2D)
int wPwl[288];                             // Powerwall SOC per bin
int wMsoc[288];                            // Marstek Venus SOC per bin (-1 = no data yet, skipped)
int wp = 0; int cnt = 0;                   // ring write pointer + filled count
int save_tog = 0;                          // ping-pong: which file to write next (0=/solar.bin 1=/solar2.bin)
int save_seq = 0;                          // monotonic save sequence (header) — loadHist picks the newest valid file
int gln[4];                                // 4 solar polyline handles
int plLine = 0;                            // Powerwall polyline handle
int mkLine = 0;                            // Marstek Venus SOC polyline handle
int xlS[5]; int xlP[5];                    // x-axis HH:MM label handles (solar / powerwall)
int g_needle = 0; int g_value = 0;         // live gauge parts
int g_cx; int g_cy; int g_r; int g_pmax;
int g_vals[5];                             // raw-value readout labels (control)
int tick = 0;                              // sample counter (EverySecond ticks)
int seeded = 0;                            // 1 while the ring holds synthetic seed data

int ymap(int v, int vmin, int vmax, int y0, int h) {
    return y0 + h - ((v - vmin) * h) / (vmax - vmin);
}

void card(int x, int y, int w, int h) {
    int c = lvglObj(0);
    lvglSetPos(c, x, y); lvglSetSize(c, w, h);
    lvglSetBgColor(c, C_CARD);
    lvglSetStyleInt(c, ST_RADIUS, 12);
    lvglSetStyleInt(c, ST_BORDER, 1);
    lvglSetStyleInt(c, ST_BOPA, 255);
    lvglSetStyleInt(c, ST_BCOLOR, C_CARDB);
}

void plotBg(int x, int y, int w, int h) {
    int p = lvglObj(0);
    lvglSetStyleInt(p, ST_BORDER, 0);
    lvglSetSize(p, w, h);
    lvglSetStyleInt(p, ST_RADIUS, 0);
    lvglSetBgColor(p, C_PLOT);
    lvglSetPos(p, x, y);
}

void drawGrid(int x0, int y0, int w, int h, int ndiv, int nxdiv) {
    int i = 0;
    while (i <= ndiv) {
        int gy = y0 + (h * i) / ndiv;
        int gl = lvglLine(0);
        if (i == ndiv) { lvglLineStyle(gl, C_BASE, 1); } else { lvglLineStyle(gl, C_GRID, 1); }
        lvglLinePoints(gl, x0, gy, x0 + w, gy);
        i = i + 1;
    }
    if (nxdiv >= 1) {
        i = 0;
        while (i <= nxdiv) {
            int gx = x0 + (w * i) / nxdiv;
            int vl = lvglLine(0);
            lvglLineStyle(vl, C_GRID, 1);
            lvglLinePoints(vl, gx, y0, gx, y0 + h);
            i = i + 1;
        }
    }
}

void yAxis(int x, int y0, int h, int vmin, int vmax, int ndiv, int color) {
    int i = 0;
    while (i <= ndiv) {
        int gy = y0 + (h * i) / ndiv;
        int val = vmax - ((vmax - vmin) * i) / ndiv;
        char lb[12]; sprintf(lb, "%d", val);
        int t = lvglLabel(0);
        lvglSetText(t, lb); lvglSetTextColor(t, color); lvglSetFont(t, 14);
        lvglSetPos(t, x, gy - 11);
        i = i + 1;
    }
}

// create the 5 x-axis tick labels (HH:MM, set later) and store their handles in dst[]
void makeXLabels(int dst[], int x0, int w, int y) {
    int i = 0;
    while (i <= 4) {
        int lx = x0 + (w * i) / 4;
        int t = lvglLabel(0);
        lvglSetTextColor(t, C_AXIS); lvglSetFont(t, 14);
        lvglSetPos(t, lx - 18, y);          // ~centre "HH:MM"
        dst[i] = t;
        i = i + 1;
    }
}

// rolling 24 h window: right edge = now, each tick steps back 6 h. Refresh HH:MM each redraw.
void updateXLabels() {
    char ts[24];
    timeStamp(ts);                                  // local "YYYY-MM-DDTHH:MM:SS" (HH at 11,12 / MM at 14,15)
    int nowMin = ((ts[11] - 48) * 10 + (ts[12] - 48)) * 60 + (ts[14] - 48) * 10 + (ts[15] - 48);
    int i = 0;
    while (i <= 4) {
        int tm = nowMin - (4 - i) * 360;
        tm = ((tm % 1440) + 1440) % 1440;
        char lb[8];
        sprintf(lb, "%02d:%02d", tm / 60, tm - (tm / 60) * 60);
        lvglSetText(xlS[i], lb);
        lvglSetText(xlP[i], lb);
        i = i + 1;
    }
}

void legSquare(int x, int y, int color) {
    int s = lvglObj(0);
    lvglSetStyleInt(s, ST_BORDER, 0);
    lvglSetSize(s, 12, 12);
    lvglSetStyleInt(s, ST_RADIUS, 2);
    lvglSetBgColor(s, color);
    lvglSetPos(s, x, y);
}

// plot a ring row (24 h) as a polyline into an existing line handle
void plotSeries(int row[], int handle, int x0, int y0, int w, int h, int vmax) {
    int j = 0; int n = 0;
    while (j < cnt) {
        int idx = (wp - cnt + j + 576) % 288;        // chronological: oldest -> newest
        if (row[idx] >= 0) {                          // skip -1 sentinel bins (Venus SOC before its first sample)
            px[n] = x0 + w - (w * (cnt - 1 - j)) / 287;  // newest at the right edge (now); 5 min = w/287
            py[n] = ymap(row[idx], 0, vmax, y0, h);
            n = n + 1;
        }
        j = j + 1;
    }
    if (n >= 2) { lvglLinePoly(handle, px, py, n); }
}

void updateCharts() {
    plotSeries(wSolar[0], gln[0], SX, SY, SW, SH, SOLAR_MAX_W);
    plotSeries(wSolar[1], gln[1], SX, SY, SW, SH, SOLAR_MAX_W);
    plotSeries(wSolar[2], gln[2], SX, SY, SW, SH, SOLAR_MAX_W);
    plotSeries(wSolar[3], gln[3], SX, SY, SW, SH, SOLAR_MAX_W);
    plotSeries(wPwl, plLine, PX, PY, PW, PH, 100);
    plotSeries(wMsoc, mkLine, PX, PY, PW, PH, 100);
    updateXLabels();
}

// inverters report production with mixed sign (3 are negative) — chart/gauge use the magnitude
float pabs(float v) { if (v < 0.0) { return 0.0 - v; } return v; }

// Persist the rolling history — POWER-SAFE ping-pong. Truncate-write of the ONE
// file (the old way) had a fatal window: a power cut mid-write left solar.bin
// partial, loadHist() then failed, and main() re-seeded -> the whole ring reset
// (that lost the history across the outage). Instead we alternate /solar.bin and
// /solar2.bin with a rising seq in the header, so a crash can only corrupt the
// file being written; the previous one stays intact. (TinyC has no fileRename,
// so this is the atomic-replace substitute.)
void saveHist() {
    save_seq = save_seq + 1;
    int hdr[4]; hdr[0] = 0x534F4C31; hdr[1] = wp; hdr[2] = cnt; hdr[3] = save_seq;   // magic 'SOL1' + seq
    int h;
    if (save_tog == 0) { h = fileOpen("/solar.bin", 1); }
    else { h = fileOpen("/solar2.bin", 1); }
    if (h < 0) { return; }
    fileWriteBin(h, hdr, 4);
    fileWriteBin(h, wSolar[0], 288);
    fileWriteBin(h, wSolar[1], 288);
    fileWriteBin(h, wSolar[2], 288);
    fileWriteBin(h, wSolar[3], 288);
    fileWriteBin(h, wPwl, 288);
    fileClose(h);
    save_tog = 1 - save_tog;                        // next save -> the other file
}

// Reload persisted history on boot. Probe BOTH ping-pong files; use the newest one
// that is FULLY valid (magic + exact size). A partial/corrupt file (short size) is
// skipped, so a power cut can never wipe the history. Returns 1 if a valid file was
// read. (The old single pre-outage solar.bin has seq 0 and still loads.)
int loadHist() {
    int seq0; int seq1; int hdr[4];
    seq0 = -1; seq1 = -1;
    if (fileSize("/solar.bin") == SOLBIN_SIZE) {
        int h = fileOpen("/solar.bin", 0);
        if (h >= 0) { fileReadBin(h, hdr, 4); if (hdr[0] == 0x534F4C31) { seq0 = hdr[3]; } fileClose(h); }
    }
    if (fileSize("/solar2.bin") == SOLBIN_SIZE) {
        int h = fileOpen("/solar2.bin", 0);
        if (h >= 0) { fileReadBin(h, hdr, 4); if (hdr[0] == 0x534F4C31) { seq1 = hdr[3]; } fileClose(h); }
    }
    if (seq0 < 0 && seq1 < 0) { return 0; }         // neither valid -> caller seeds
    int h;
    // load the newer; point the next save at the OTHER file so the newest survives it
    if (seq1 > seq0) { h = fileOpen("/solar2.bin", 0); save_tog = 0; }
    else             { h = fileOpen("/solar.bin", 0);  save_tog = 1; }
    if (h < 0) { return 0; }
    fileReadBin(h, hdr, 4);
    wp = hdr[1]; cnt = hdr[2]; save_seq = hdr[3];
    fileReadBin(h, wSolar[0], 288);
    fileReadBin(h, wSolar[1], 288);
    fileReadBin(h, wSolar[2], 288);
    fileReadBin(h, wSolar[3], 288);
    fileReadBin(h, wPwl, 288);
    fileClose(h);
    return 1;
}

void sampleNow() {
    if (seeded) { seeded = 0; wp = 0; cnt = 0; }    // drop the synthetic seed on the first real sample
    wSolar[0][wp] = (int)pabs(sedc);
    wSolar[1][wp] = (int)pabs(wrgh);
    wSolar[2][wp] = (int)pabs(wrga);
    wSolar[3][wp] = (int)pabs(wrgg);
    wPwl[wp] = (int)pwl;
    wMsoc[wp] = (int)msoc;
    wp = (wp + 1) % 288;
    if (cnt < 288) { cnt = cnt + 1; }
    saveHist();                                     // persist after each sample
}

void seedData() {
    float pk0 = 4500.0; float pk1 = 2000.0; float pk2 = 1500.0; float pk3 = 1000.0;
    int j = 0;
    while (j < 288) {
        float t = 24.0 * (float)j / 288.0;
        float hump = 0.0;
        if (t > 6.0) { if (t < 20.0) { float s = sin(PI * (t - 6.0) / 14.0); hump = s * s; } }
        wSolar[0][j] = (int)(pk0 * hump);
        wSolar[1][j] = (int)(pk1 * hump);
        wSolar[2][j] = (int)(pk2 * hump);
        wSolar[3][j] = (int)(pk3 * hump);
        int soc;
        if (t < 9.0) { soc = 30; }
        else { if (t < 16.0) { soc = 30 + (int)(65.0 * (t - 9.0) / 7.0); }
        else { soc = 95 - (int)(55.0 * (t - 16.0) / 8.0); } }
        wPwl[j] = soc;
        j = j + 1;
    }
    wp = 0; cnt = 288;
}

// ---- gauge (live sum) ----
void needlePoint(int value) {
    float pi = PI;
    float fv = (float)value / (float)g_pmax;
    if (fv < 0.0) { fv = 0.0; } if (fv > 1.0) { fv = 1.0; }
    float av = pi - pi * fv;
    int nx = g_cx + (int)((float)(g_r - 22) * cos(av));
    int ny = g_cy - (int)((float)(g_r - 22) * sin(av));
    lvglLinePoints(g_needle, g_cx, g_cy, nx, ny);
}

void zoneArc(int cx, int cy, int r, int p0, int p1, int color, int thick) {
    int N = 14; int i = 0; float pi = PI;
    while (i <= N) {
        float f = ((float)p0 + (float)(p1 - p0) * ((float)i / (float)N)) / 100.0;
        float a = pi - pi * f;
        px[i] = cx + (int)((float)r * cos(a));
        py[i] = cy - (int)((float)r * sin(a));
        i = i + 1;
    }
    int arc = lvglLine(0);
    lvglLineStyle(arc, color, thick);
    lvglLinePoly(arc, px, py, N + 1);
}

void gaugeScale(int cx, int cy, int r, int vmax, int nticks) {
    float pi = PI; int i = 0;
    while (i <= nticks) {
        float f = (float)i / (float)nticks;
        float a = pi - pi * f;
        int lx = cx + (int)((float)(r + 24) * cos(a));
        int ly = cy - (int)((float)(r + 24) * sin(a));
        int val = (vmax * i) / nticks;
        char lb[8]; sprintf(lb, "%d", val);
        int t = lvglLabel(0);
        lvglSetText(t, lb); lvglSetTextColor(t, C_AXIS); lvglSetFont(t, 14);
        lvglSetPos(t, lx - 7, ly - 9);
        i = i + 1;
    }
}

void buildGauge(int cx, int cy, int r) {
    int thick = 18;
    zoneArc(cx, cy, r, 0, 60, C_GREEN, thick);
    zoneArc(cx, cy, r, 60, 85, C_AMBER, thick);
    zoneArc(cx, cy, r, 85, 100, C_RED, thick);
    g_cx = cx; g_cy = cy; g_r = r; g_pmax = GAUGE_MAXT;
    g_needle = lvglLine(0);
    lvglLineStyle(g_needle, C_TITLE, 5);
    needlePoint(0);
    int hub = lvglObj(0);
    lvglSetStyleInt(hub, ST_BORDER, 0);
    lvglSetSize(hub, 20, 20);
    lvglSetStyleInt(hub, ST_RADIUS, 10);
    lvglSetBgColor(hub, C_TITLE);
    lvglSetPos(hub, cx - 10, cy - 10);
}

void updateGauge(int valuet) {
    needlePoint(valuet);
    char b[12];
    sprintf(b, "%d.%d", valuet / 10, valuet - (valuet / 10) * 10);
    lvglSetText(g_value, b);
}

// raw live values of all 5 globals beneath the gauge (for control / verification)
void updateReadout() {
    char b[24];
    sprintf(b, "Dach %d",   (int)sedc); lvglSetText(g_vals[0], b);
    sprintf(b, "GHaus %d",  (int)wrgh); lvglSetText(g_vals[1], b);
    sprintf(b, "Garage %d", (int)wrga); lvglSetText(g_vals[2], b);
    sprintf(b, "Garten %d", (int)wrgg); lvglSetText(g_vals[3], b);
    sprintf(b, "SOC %d",    (int)pwl);  lvglSetText(g_vals[4], b);
}

int main() {
    int mi = 0; while (mi < 288) { wMsoc[mi] = -1; mi = mi + 1; }   // Venus SOC starts empty (skipped until first sample)
    lvglInit();
    lvglClean(0);
    lvglSetBgColor(0, C_BG);

    // ===== CHART 1: solar — 4 inverters, one y-axis (kW) =====
    card(40, 28, 720, 462);
    int t1 = lvglLabel(0);
    lvglSetText(t1, "Solar power"); lvglSetTextColor(t1, C_TITLE); lvglSetFont(t1, 20); lvglSetPos(t1, 64, 46);
    legSquare(410, 50, C_BLUE);  int la = lvglLabel(0); lvglSetText(la, "Dach");   lvglSetTextColor(la, C_AXIS); lvglSetFont(la, 14); lvglSetPos(la, 426, 47);
    legSquare(496, 50, C_RED);   int lb = lvglLabel(0); lvglSetText(lb, "GHaus");  lvglSetTextColor(lb, C_AXIS); lvglSetFont(lb, 14); lvglSetPos(lb, 512, 47);
    legSquare(592, 50, C_GREEN); int lc = lvglLabel(0); lvglSetText(lc, "Garage"); lvglSetTextColor(lc, C_AXIS); lvglSetFont(lc, 14); lvglSetPos(lc, 608, 47);
    legSquare(690, 50, C_AMBER); int ld = lvglLabel(0); lvglSetText(ld, "Garten"); lvglSetTextColor(ld, C_AXIS); lvglSetFont(ld, 14); lvglSetPos(ld, 706, 47);
    plotBg(SX - 4, SY - 4, SW + 8, SH + 8);
    drawGrid(SX, SY, SW, SH, 5, 4);
    yAxis(56, SY, SH, 0, 5, 5, C_AXIS);             // kW labels 0..5
    makeXLabels(xlS, SX, SW, SY + SH + 10);
    gln[0] = lvglLine(0); lvglLineStyle(gln[0], C_BLUE, 3);
    gln[1] = lvglLine(0); lvglLineStyle(gln[1], C_RED, 3);
    gln[2] = lvglLine(0); lvglLineStyle(gln[2], C_GREEN, 3);
    gln[3] = lvglLine(0); lvglLineStyle(gln[3], C_AMBER, 3);

    // ===== CHART 2: Powerwall SOC (%) =====
    card(40, 510, 720, 300);
    int t2 = lvglLabel(0);
    lvglSetText(t2, "Battery SOC"); lvglSetTextColor(t2, C_TITLE); lvglSetFont(t2, 20); lvglSetPos(t2, 64, 528);
    legSquare(410, 532, C_GREEN); int lp = lvglLabel(0); lvglSetText(lp, "Powerwall"); lvglSetTextColor(lp, C_AXIS); lvglSetFont(lp, 14); lvglSetPos(lp, 426, 529);
    legSquare(580, 532, C_BLUE);  int lm = lvglLabel(0); lvglSetText(lm, "Marstek");   lvglSetTextColor(lm, C_AXIS); lvglSetFont(lm, 14); lvglSetPos(lm, 596, 529);
    plotBg(PX - 4, PY - 4, PW + 8, PH + 8);
    drawGrid(PX, PY, PW, PH, 4, 4);
    yAxis(52, PY, PH, 0, 100, 4, C_AXIS);
    makeXLabels(xlP, PX, PW, PY + PH + 10);
    plLine = lvglLine(0); lvglLineStyle(plLine, C_GREEN, 3);
    mkLine = lvglLine(0); lvglLineStyle(mkLine, C_BLUE, 3);   // Venus SOC (blue) on the same chart

    // ===== GAUGE: live sum of the 4 inverters (kW) =====
    card(40, 830, 720, 400);
    int t3 = lvglLabel(0);
    lvglSetText(t3, "Solar total"); lvglSetTextColor(t3, C_TITLE); lvglSetFont(t3, 20); lvglSetPos(t3, 64, 848);
    buildGauge(400, 1110, 130);
    g_value = lvglLabel(0);
    lvglSetText(g_value, "0.0"); lvglSetTextColor(g_value, C_TITLE); lvglSetFont(g_value, 28); lvglAlign(g_value, AL_CENTER, 0, 405);
    int gu = lvglLabel(0);
    lvglSetText(gu, "kW"); lvglSetTextColor(gu, C_AXIS); lvglSetFont(gu, 14); lvglAlign(gu, AL_CENTER, 0, 442);
    gaugeScale(400, 1110, 130, 10, 5);

    // raw-value readout beneath the gauge (control), colour-keyed to the series
    g_vals[0] = lvglLabel(0); lvglSetTextColor(g_vals[0], C_BLUE);  lvglSetFont(g_vals[0], 14); lvglSetPos(g_vals[0], 56,  1182);
    g_vals[1] = lvglLabel(0); lvglSetTextColor(g_vals[1], C_RED);   lvglSetFont(g_vals[1], 14); lvglSetPos(g_vals[1], 194, 1182);
    g_vals[2] = lvglLabel(0); lvglSetTextColor(g_vals[2], C_GREEN); lvglSetFont(g_vals[2], 14); lvglSetPos(g_vals[2], 332, 1182);
    g_vals[3] = lvglLabel(0); lvglSetTextColor(g_vals[3], C_AMBER); lvglSetFont(g_vals[3], 14); lvglSetPos(g_vals[3], 470, 1182);
    g_vals[4] = lvglLabel(0); lvglSetTextColor(g_vals[4], C_AXIS);  lvglSetFont(g_vals[4], 14); lvglSetPos(g_vals[4], 608, 1182);
    updateReadout();

    if (loadHist() == 0) {         // real history from /solar.bin (SD) if present, else seed a demo day
#if SEED
        seedData(); seeded = 1;
#endif
    }
    updateCharts();
    return 0;                       // main MUST return — a while(1) here blocks the firmware
}                                   // from injecting UDP global updates (the vars would stay 0)

// Tasmota calls this every second AFTER main returns, so the global vars stay live.
// Light work, no delay: live gauge + control readout; 5-min history sample + redraw.
void EverySecond() {
    float sum = pabs(sedc) + pabs(wrgh) + pabs(wrga) + pabs(wrgg);
    updateGauge((int)(sum / 100.0));            // W -> tenths of kW (production magnitude)
    updateReadout();                            // live raw values for control
    tick = tick + 1;
    if (tick >= SAMPLE_SEC) {                   // sample + persist + redraw
        tick = 0;
        sampleNow();
        updateCharts();
    }
}