solar_dashboard.tc¶
solar_dashboard.tc — REAL energy dashboard on LVGL (P4), built on the gchart primitives.
// @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();
}
}