Skip to content

solar_dashboard.tc

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

Source on GitHub

// @name: Solar Dashboard (LVGL)
// 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 + Marstek SOC (%), 24 h / 5 min
//   GAUGE  : live SUM of the 4 inverters (kW), real-time
//   CHART 3: (landscape only) Powerwall + Marstek SOC over 7 days / 30 min, full width,
//            plus the TOTAL solar production (30-min mean, kW on a right-hand axis)
//
// Data comes from UDP-published Scripter global vars (read-only consume):
//   sedc=Dach, wrgh=Gartenhaus, wrga=Garage, wrgg=Garten, pwl=Powerwall SOC, msoc=Marstek SOC.
// History is a rolling 24 h ring buffer (288 x 5 min) sampled live every 5 min, plus a
// 7-day ring (336 x 30 min) for the two SOCs, sampled every 6th 5-min tick, and one for
// the solar total, which is the MEAN of those six 5-min samples (a snapshot every 30 min
// would miss most of a cloudy day).
// 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.
//
// LAYOUT follows the panel orientation (DisplayRotate + reboot, see the reference):
//   portrait  (800 x 1280): the three panels stacked, as before
//   landscape (1280 x 800): the three panels across the top, the 7-day SOC chart
//                           over the full width below
// dspWidth()/dspHeight() report the ROTATED size, so nothing else has to be configured.

// ---- 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 NWEEK        336          // 7 d / 30 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 WEEK_EVERY   6            // every 6th 5-min sample = 30 min -> the 7-day ring
// file sizes (power-safe check): SOL1 = 4-int hdr + 5 x 288 x 4 B (pre-Marstek, pre-week)
//                               SOL2 = 8-int hdr + 6 x 288 x 4 B + 2 x 336 x 4 B
//                               SOL3 = SOL2 + 1 x 336 x 4 B (the 7-day solar total)
#define SOLBIN_SIZE1 5776
#define SOLBIN_SIZE2 9632
#define SOLBIN_SIZE3 10976
#define WEEK_SOL_MAX_W 10000      // right-hand axis of the 7-day chart: 0..10 kW (like the gauge)

#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

// ---- plot rectangles, set by the layout in main() (portrait keeps the old numbers) ----
int LAND = 0;                              // 1 = landscape layout
int SX; int SY; int SW; int SH;            // solar plot
int PX; int PY; int PW; int PH;            // 24 h SOC plot
int WX; int WY; int WW; int WH;            // 7-day SOC plot (landscape only)

int px[340]; int py[340];                  // polyline scratch (>= NWEEK)
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 wkPwl[336];                            // 7-day ring: Powerwall SOC per 30 min
int wkMsoc[336];                           // 7-day ring: Marstek SOC per 30 min (-1 = none)
int wkSol[336];                            // 7-day ring: solar total, W, mean over 30 min (-1 = none)
int wkSolAcc = 0; int wkSolN = 0;          // running sum + count of the 5-min totals of the open 30-min bin
int wkp = 0; int wkcnt = 0;                // 7-day ring write pointer + filled count
int wkTick = 0;                            // counts 5-min samples towards the next 30-min bin
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 wkLineP = 0; int wkLineM = 0;          // 7-day polyline handles
int wkLineS = 0;                           // 7-day solar total polyline
int xlS[5]; int xlP[5];                    // x-axis HH:MM label handles (solar / powerwall)
int xlW[8];                                // 7-day chart: weekday labels at the midnights
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[6];                             // 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;
    }
}

// a titled legend entry: colour square + text
void legend(int x, int y, int color, char text[]) {
    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 + 3);
    int t = lvglLabel(0);
    lvglSetText(t, text); lvglSetTextColor(t, C_AXIS); lvglSetFont(t, 14); lvglSetPos(t, x + 16, y);
}

void title(int x, int y, char text[]) {
    int t = lvglLabel(0);
    lvglSetText(t, text); lvglSetTextColor(t, C_TITLE); lvglSetFont(t, 20); lvglSetPos(t, x, y);
}

// 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;
    }
}

// 7-day chart: eight weekday labels, created once, placed at the midnights on every redraw.
void makeWeekLabels(int y) {
    int i = 0;
    while (i < 8) {
        int t = lvglLabel(0);
        lvglSetTextColor(t, C_AXIS); lvglSetFont(t, 14);
        lvglSetText(t, "");
        lvglSetPos(t, WX, y);
        xlW[i] = t;
        i = i + 1;
    }
}

void weekdayName(int d, char out[]) {          // 0 = Sunday .. 6 = Saturday
    if (d == 0) { strcpy(out, "So"); }
    if (d == 1) { strcpy(out, "Mo"); }
    if (d == 2) { strcpy(out, "Di"); }
    if (d == 3) { strcpy(out, "Mi"); }
    if (d == 4) { strcpy(out, "Do"); }
    if (d == 5) { strcpy(out, "Fr"); }
    if (d == 6) { strcpy(out, "Sa"); }
}

// The right edge of the 7-day chart is now; a midnight k days back sits
// (minutes since midnight + k*24h) / 30 min bins to the left of it.
void updateWeekLabels() {
    char ts[24];
    timeStamp(ts);
    int nowMin = ((ts[11] - 48) * 10 + (ts[12] - 48)) * 60 + (ts[14] - 48) * 10 + (ts[15] - 48);
    int secs = timeToSecs(ts);                      // local-as-UTC, fine for the weekday
    int today = ((secs / 86400) + 4) % 7;           // 1970-01-01 was a Thursday (4), 0 = Sunday
    int k = 0;
    while (k < 8) {
        int bins = nowMin / 30 + k * 48;            // bins back from the newest one
        if (bins <= NWEEK - 1) {
            char nm[4];
            weekdayName(((today - k) % 7 + 7) % 7, nm);
            lvglSetText(xlW[k], nm);
            lvglSetPos(xlW[k], WX + WW - (WW * bins) / (NWEEK - 1) - 8, WY + WH + 10);
        } else {
            lvglSetText(xlW[k], "");
        }
        k = k + 1;
    }
}

// plot a ring row as a polyline into an existing line handle. `n_ring` bins, write
// pointer `w_ptr`, `n_cnt` filled -- the 24 h ring (288) and the 7-day ring (336)
// share this. -1 bins (no data yet) are skipped.
void plotRing(int row[], int handle, int n_ring, int w_ptr, int n_cnt, int x0, int y0, int w, int h, int vmax) {
    int j = 0; int n = 0;
    while (j < n_cnt) {
        int idx = (w_ptr - n_cnt + j + 2 * n_ring) % n_ring;   // chronological: oldest -> newest
        if (row[idx] >= 0) {
            px[n] = x0 + w - (w * (n_cnt - 1 - j)) / (n_ring - 1);   // newest at the right edge (now)
            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() {
    plotRing(wSolar[0], gln[0], NBINS, wp, cnt, SX, SY, SW, SH, SOLAR_MAX_W);
    plotRing(wSolar[1], gln[1], NBINS, wp, cnt, SX, SY, SW, SH, SOLAR_MAX_W);
    plotRing(wSolar[2], gln[2], NBINS, wp, cnt, SX, SY, SW, SH, SOLAR_MAX_W);
    plotRing(wSolar[3], gln[3], NBINS, wp, cnt, SX, SY, SW, SH, SOLAR_MAX_W);
    plotRing(wPwl, plLine, NBINS, wp, cnt, PX, PY, PW, PH, 100);
    plotRing(wMsoc, mkLine, NBINS, wp, cnt, PX, PY, PW, PH, 100);
    updateXLabels();
    if (LAND) {
        plotRing(wkPwl, wkLineP, NWEEK, wkp, wkcnt, WX, WY, WW, WH, 100);
        plotRing(wkMsoc, wkLineM, NWEEK, wkp, wkcnt, WX, WY, WW, WH, 100);
        plotRing(wkSol, wkLineS, NWEEK, wkp, wkcnt, WX, WY, WW, WH, WEEK_SOL_MAX_W);
        updateWeekLabels();
    }
}

// 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.)
// SOL2 (18.09.2026): the Marstek ring and the 7-day rings are in the file too --
// the Marstek chart used to come back EMPTY after every reboot because saveHist()
// never wrote wMsoc (gemu: "der marstek soc chart wird nicht gespeichert").
void saveHist() {
    save_seq = save_seq + 1;
    int hdr[8];
    hdr[0] = 0x534F4C33; hdr[1] = wp; hdr[2] = cnt; hdr[3] = save_seq;   // magic 'SOL3' + seq
    hdr[4] = wkp; hdr[5] = wkcnt; hdr[6] = wkTick; hdr[7] = wkSolAcc;     // open 30-min bin survives a reboot
    int h;
    if (save_tog == 0) { h = fileOpen("/solar.bin", 1); }
    else { h = fileOpen("/solar2.bin", 1); }
    if (h < 0) { return; }
    fileWriteBin(h, hdr, 8);
    fileWriteBin(h, wSolar[0], 288);
    fileWriteBin(h, wSolar[1], 288);
    fileWriteBin(h, wSolar[2], 288);
    fileWriteBin(h, wSolar[3], 288);
    fileWriteBin(h, wPwl, 288);
    fileWriteBin(h, wMsoc, 288);
    fileWriteBin(h, wkPwl, 336);
    fileWriteBin(h, wkMsoc, 336);
    fileWriteBin(h, wkSol, 336);
    fileClose(h);
    save_tog = 1 - save_tog;                        // next save -> the other file
}

// First load of an older file (SOL1/SOL2): the 7-day solar ring does not exist yet,
// but the 24 h ring does -- so the last day of the week chart can be filled right
// away instead of growing for a day. The newest closed 30-min bin is made of the six
// 5-min samples that ended `wkTick` samples ago; each older bin is six further back.
void backfillWeekSolar() {
    int b = 0;
    while (b < wkcnt) {
        int off = wkTick + 6 * b;                  // samples back from the newest one
        if (off + 5 >= cnt) { break; }             // the 24 h ring does not reach back that far
        int sum = 0; int k = 0;
        while (k < 6) {
            int idx = (wp - 1 - off - k + 2 * NBINS) % NBINS;
            sum = sum + wSolar[0][idx] + wSolar[1][idx] + wSolar[2][idx] + wSolar[3][idx];
            k = k + 1;
        }
        int v = sum / 6;
        if (v > WEEK_SOL_MAX_W) { v = WEEK_SOL_MAX_W; }
        wkSol[(wkp - 1 - b + 2 * NWEEK) % NWEEK] = v;
        b = b + 1;
    }
    wkSolAcc = 0; wkSolN = 0;                      // the open bin starts clean
}

// A file is valid when magic and size agree: SOL1 (old, 5 arrays), SOL2 (6 + 2 arrays)
// or SOL3 (6 + 3 arrays, with the 7-day solar total).
// `which` 0 = /solar.bin, 1 = /solar2.bin (file syscalls want literal names).
// Returns the seq of a valid file, -1 otherwise.
int histSeq(int which) {
    int sz; int h;
    if (which == 0) { sz = fileSize("/solar.bin"); } else { sz = fileSize("/solar2.bin"); }
    if (sz != SOLBIN_SIZE1 && sz != SOLBIN_SIZE2 && sz != SOLBIN_SIZE3) { return -1; }
    int hdr[4];
    if (which == 0) { h = fileOpen("/solar.bin", 0); } else { h = fileOpen("/solar2.bin", 0); }
    if (h < 0) { return -1; }
    fileReadBin(h, hdr, 4);
    fileClose(h);
    if (sz == SOLBIN_SIZE1 && hdr[0] == 0x534F4C31) { return hdr[3]; }
    if (sz == SOLBIN_SIZE2 && hdr[0] == 0x534F4C32) { return hdr[3]; }
    if (sz == SOLBIN_SIZE3 && hdr[0] == 0x534F4C33) { return hdr[3]; }
    return -1;
}

int readHist(int which) {
    int sz; int h;
    if (which == 0) { sz = fileSize("/solar.bin"); h = fileOpen("/solar.bin", 0); }
    else { sz = fileSize("/solar2.bin"); h = fileOpen("/solar2.bin", 0); }
    if (h < 0) { return 0; }
    int hdr[8];
    if (sz == SOLBIN_SIZE1) {
        fileReadBin(h, hdr, 4);
        wp = hdr[1]; cnt = hdr[2]; save_seq = hdr[3];
    } else {
        fileReadBin(h, hdr, 8);
        wp = hdr[1]; cnt = hdr[2]; save_seq = hdr[3];
        wkp = hdr[4]; wkcnt = hdr[5]; wkTick = hdr[6];
        if (sz == SOLBIN_SIZE3) { wkSolAcc = hdr[7]; wkSolN = wkTick; }
    }
    fileReadBin(h, wSolar[0], 288);
    fileReadBin(h, wSolar[1], 288);
    fileReadBin(h, wSolar[2], 288);
    fileReadBin(h, wSolar[3], 288);
    fileReadBin(h, wPwl, 288);
    if (sz == SOLBIN_SIZE2 || sz == SOLBIN_SIZE3) {
        fileReadBin(h, wMsoc, 288);
        fileReadBin(h, wkPwl, 336);
        fileReadBin(h, wkMsoc, 336);
    }
    if (sz == SOLBIN_SIZE3) { fileReadBin(h, wkSol, 336); }
    fileClose(h);
    if (sz != SOLBIN_SIZE3) { backfillWeekSolar(); }   // older file: no solar ring yet
    return 1;
}

// 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. An old SOL1 file (pre-Marstek) still loads; its Marstek/week rings stay empty.
int loadHist() {
    int seq0 = histSeq(0);
    int seq1 = histSeq(1);
    if (seq0 < 0 && seq1 < 0) { return 0; }         // neither valid -> caller seeds
    // load the newer; point the next save at the OTHER file so the newest survives it
    if (seq1 > seq0) { save_tog = 0; return readHist(1); }
    save_tog = 1;
    return readHist(0);
}

void sampleNow() {
    if (seeded) { seeded = 0; wp = 0; cnt = 0; }    // drop the synthetic seed on the first real sample
    int ms = -1;
    if (msoc > 0.0) { ms = (int)msoc; }             // 0 = the Venus has not published yet
    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] = ms;
    wp = (wp + 1) % 288;
    if (cnt < 288) { cnt = cnt + 1; }
    wkSolAcc = wkSolAcc + (int)(pabs(sedc) + pabs(wrgh) + pabs(wrga) + pabs(wrgg));
    wkSolN = wkSolN + 1;
    wkTick = wkTick + 1;
    if (wkTick >= WEEK_EVERY) {                     // every 30 min: one bin of the 7-day rings
        wkTick = 0;
        wkPwl[wkp] = (int)pwl;
        wkMsoc[wkp] = ms;
        int sv = -1;
        if (wkSolN > 0) { sv = wkSolAcc / wkSolN; if (sv > WEEK_SOL_MAX_W) { sv = WEEK_SOL_MAX_W; } }
        wkSol[wkp] = sv;                            // mean of the 30 min, not a snapshot
        wkSolAcc = 0; wkSolN = 0;
        wkp = (wkp + 1) % 336;
        if (wkcnt < 336) { wkcnt = wkcnt + 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);
    // the three feed-in inverters report negative while producing -- shown with the sign flipped
    sprintf(b, "GHaus %d",  (int)(0.0 - wrgh)); lvglSetText(g_vals[1], b);
    sprintf(b, "Garage %d", (int)(0.0 - wrga)); lvglSetText(g_vals[2], b);
    sprintf(b, "Garten %d", (int)(0.0 - wrgg)); lvglSetText(g_vals[3], b);
    sprintf(b, "Powerwall %d", (int)pwl);  lvglSetText(g_vals[4], b);
    sprintf(b, "Marstek %d", (int)msoc);   lvglSetText(g_vals[5], b);
}

// the readout labels, colour-keyed to the series; `dx`/`dy` = step between them, `fs` = font size
void makeReadout(int x, int y, int dx, int dy, int fs) {
    g_vals[0] = lvglLabel(0); lvglSetTextColor(g_vals[0], C_BLUE);  lvglSetFont(g_vals[0], fs); lvglSetPos(g_vals[0], x,          y);
    g_vals[1] = lvglLabel(0); lvglSetTextColor(g_vals[1], C_RED);   lvglSetFont(g_vals[1], fs); lvglSetPos(g_vals[1], x + dx,     y + dy);
    g_vals[2] = lvglLabel(0); lvglSetTextColor(g_vals[2], C_GREEN); lvglSetFont(g_vals[2], fs); lvglSetPos(g_vals[2], x + 2 * dx, y + 2 * dy);
    g_vals[3] = lvglLabel(0); lvglSetTextColor(g_vals[3], C_AMBER); lvglSetFont(g_vals[3], fs); lvglSetPos(g_vals[3], x + 3 * dx, y + 3 * dy);
    g_vals[4] = lvglLabel(0); lvglSetTextColor(g_vals[4], C_GREEN); lvglSetFont(g_vals[4], fs); lvglSetPos(g_vals[4], x + 4 * dx, y + 4 * dy);
    g_vals[5] = lvglLabel(0); lvglSetTextColor(g_vals[5], C_BLUE);  lvglSetFont(g_vals[5], fs); lvglSetPos(g_vals[5], x + 5 * dx, y + 5 * dy);
}

// the solar chart card; plot rect from SX/SY/SW/SH, legend row at (lx, ly)
void buildSolar(int cx, int cy, int cw, int ch, int lx, int ly) {
    card(cx, cy, cw, ch);
    title(cx + 24, cy + 18, "Solar power");
    legend(lx,       ly, C_BLUE,  "Dach");
    legend(lx + 86,  ly, C_RED,   "GHaus");
    legend(lx + 182, ly, C_GREEN, "Garage");
    legend(lx + 280, ly, C_AMBER, "Garten");
    plotBg(SX - 4, SY - 4, SW + 8, SH + 8);
    drawGrid(SX, SY, SW, SH, 5, 4);
    yAxis(SX - 44, 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);
}

// the 24 h SOC card
void buildSoc(int cx, int cy, int cw, int ch, int lx, int ly) {
    card(cx, cy, cw, ch);
    title(cx + 24, cy + 18, "Battery SOC");
    legend(lx,       ly, C_GREEN, "Powerwall");
    legend(lx + 120, ly, C_BLUE,  "Marstek");
    plotBg(PX - 4, PY - 4, PW + 8, PH + 8);
    drawGrid(PX, PY, PW, PH, 4, 4);
    yAxis(PX - 48, 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
}

// the 7-day SOC card (landscape): both SOCs over the full width
void buildWeek(int cx, int cy, int cw, int ch) {
    card(cx, cy, cw, ch);
    title(cx + 24, cy + 18, "Battery SOC, 7 days");
    legend(cx + 260, cy + 22, C_GREEN, "Powerwall");
    legend(cx + 380, cy + 22, C_BLUE,  "Marstek");
    legend(cx + 490, cy + 22, C_AMBER, "Solar gesamt (kW, rechts)");
    plotBg(WX - 4, WY - 4, WW + 8, WH + 8);
    drawGrid(WX, WY, WW, WH, 4, 7);
    yAxis(WX - 48, WY, WH, 0, 100, 4, C_AXIS);
    // right-hand axis for the solar total, amber like its line: 10 / 7.5 / 5 / 2.5 / 0 kW
    // on the four grid divisions (yAxis() prints integers and would say 7 and 2)
    int ri = 0;
    while (ri <= 4) {
        int rt = lvglLabel(0);
        if (ri == 0) { lvglSetText(rt, "10"); }
        if (ri == 1) { lvglSetText(rt, "7.5"); }
        if (ri == 2) { lvglSetText(rt, "5"); }
        if (ri == 3) { lvglSetText(rt, "2.5"); }
        if (ri == 4) { lvglSetText(rt, "0"); }
        lvglSetTextColor(rt, C_AMBER); lvglSetFont(rt, 14);
        lvglSetPos(rt, WX + WW + 8, WY + (WH * ri) / 4 - 11);
        ri = ri + 1;
    }
    makeWeekLabels(WY + WH + 10);
    wkLineS = lvglLine(0); lvglLineStyle(wkLineS, C_AMBER, 2);   // first = underneath the SOC lines
    wkLineP = lvglLine(0); lvglLineStyle(wkLineP, C_GREEN, 3);
    wkLineM = lvglLine(0); lvglLineStyle(wkLineM, C_BLUE, 3);
}

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

    // ---- which way round is the panel? ----
    LAND = 0;
    if (dspWidth() > dspHeight()) { LAND = 1; }

    if (LAND == 0) {
        // ===== PORTRAIT 800 x 1280: the three panels stacked (the original layout) =====
        SX = 100; SY = 110; SW = 600; SH = 330;
        PX = 100; PY = 582; PW = 600; PH = 196;
        buildSolar(40, 28, 720, 462, 410, 47);
        buildSoc(40, 510, 720, 300, 410, 529);
        card(40, 830, 720, 400);
        title(64, 848, "Solar total");
        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);
        makeReadout(56, 1182, 118, 0, 14);                         // one row beneath the gauge
    } else {
        // ===== LANDSCAPE 1280 x 800: three panels across the top, the week below =====
        SX = 100; SY = 108; SW = 380; SH = 260;         // two equal chart cards, 470 wide each
        PX = 590; PY = 108; PW = 380; PH = 260;
        WX = 100; WY = 500; WW = 1100; WH = 220;
        buildSolar(40, 28, 470, 400, 100, 70);
        buildSoc(530, 28, 470, 400, 590, 70);
        card(1020, 28, 220, 400);
        title(1044, 46, "Solar total");
        buildGauge(1130, 190, 80);                                 // gauge high up, readout column below
        g_value = lvglLabel(0);
        lvglSetText(g_value, "0.0"); lvglSetTextColor(g_value, C_TITLE); lvglSetFont(g_value, 28); lvglSetPos(g_value, 1108, 206);
        int gu = lvglLabel(0);
        lvglSetText(gu, "kW"); lvglSetTextColor(gu, C_AXIS); lvglSetFont(gu, 14); lvglSetPos(gu, 1122, 240);
        gaugeScale(1130, 190, 80, 10, 5);
        makeReadout(1040, 268, 0, 26, 20);                         // six lines, font 20 (built-in sizes: 10/14/20/28), both SOCs
        buildWeek(40, 450, 1200, 330);
    }
    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();
    }
}