marstek_venus.tc¶
marstek_venus.tc — Marstek Venus E (5.12 kWh) surplus/reserve controller.
// marstek_venus.tc — Marstek Venus E (5.12 kWh) surplus/reserve controller.
// Real control via -DMK_REAL (UDP-JSON to the Venus, live on .170); the default
// build simulates the battery so the control logic can be tested without hardware.
//
// Goal of the strategy (your case: NO dynamic tariff; relieve the aging
// Powerwall + hold a small blackout reserve):
//
// * CHARGE the Venus E first from real PV surplus -> Powerwall cycles less.
// * DISCHARGE the Venus E first to cover the house -> Powerwall discharges less.
// * Never go below mk_min (= backup reserve kept for a grid outage).
// * Never charge above mk_max.
//
// There is NO real battery here. The Venus E is fully SIMULATED: SOC is
// integrated from the computed power setpoint, so you can watch the control
// logic behave against your live Powerwall data before buying hardware.
//
// INPUTS — read-only consume of the existing UDP globals that powerwall.tc
// broadcasts on .39 (same names wallbox_charge.tc already uses):
// sop=Solar(W) hip=Haus(W) sip=Netz(W) bip=PW-Batt(W,-=laden) pwl=PW-SOC(%)
// We only READ these (never assign) — that is the safe, validated pattern.
//
// surplus = sop - hip (>0 = PV-Ueberschuss, der sonst PW/Netz laedt)
// deficit = hip - sop (>0 = Hausverbrauch, den sonst Batterie/Netz deckt)
//
// Console: MK · MK a (AUTO an/aus) · MK s (STOP) · MK c (Laden erzw.) ·
// MK d (Entladen erzw.) · MK max <n> · MK min <n> · MK soc <n> (Sim-SOC setzen)
//
// ─── Going LIVE ──────────────────────────────────────────────────────────────
// Compile with -DMK_REAL to additionally drive a real Venus E over its local
// JSON-RPC API (enable "local API" in the Marstek app; UDP port 30000, FW>=V144).
// Verified live on a VenusE 3.0 (ver 148). The MK_REAL block below implements it
// natively in TinyC via udp() — no Modbus, no bridge. Key API surface:
// read ES.GetStatus {"id":0} -> bat_soc(%), bat_cap(Wh), pv_power, ongrid_power,
// offgrid_power, total_*_energy
// ctrl ES.SetMode {"id":0,"config":{"mode":"Passive",
// "passive_cfg":{"power":<W>,"cd_time":<s>}}}
// power: NEGATIVE=charge, POSITIVE=discharge, 0=idle (±10000 W) — matches
// mk_setp's sign exactly. cd_time = auto-revert-to-idle countdown (safety).
#define MK_CAP_WH 5120 // usable energy (5.12 kWh)
#define MK_PMAX 2500 // max charge/discharge power on a dedicated circuit (W)
#define MK_EFF 93 // round-trip-ish efficiency, integer % (charge side)
#define TICK_S 30 // control + integration period (s) — matches the ~30s PW data refresh
#define DB 150 // deadband (W) — ignore tiny surplus/deficit, no hunting
#define RAMP 400 // max setpoint change per tick (W) — slew-rate limit (anti-oscillation)
#define FILT_N 4 // EMA window on the surplus/deficit input (anti-oscillation)
#define MK_SELF_COMP 1 // subtract our own draw from the balance (assumes hip meters the Venus E)
// Inputs — UDP globals from powerwall.tc (READ ONLY).
global float sop; global float hip; global float sip; global float bip; global float pwl;
// Output — broadcast the current Venus E SOC to the fleet (like powerwall.tc broadcasts pwl).
global float msoc;
// Persistent rules + simulated battery state (survive reboot, like a real cell).
persist int armed = 1; // AUTO mode on/off
persist int mk_max = 95; // stop charging at this SOC %
persist int mk_min = 20; // backup reserve — never discharge below this %
persist float mk_soc = 50.0; // SIMULATED state of charge (%)
persist int seeded = 0; // one-time default bootstrap flag
persist int mk_pwlmin = 30; // hold Marstek AUTO charging until Powerwall SOC >= this %
// (slider min 5). Both drained -> fill the Powerwall first.
persist int mk_dcap = 2500; // max DISCHARGE power (W) — MIRROR the Marstek app's
// 800/2500 output-limit switch (the Local API neither
// reads nor sets it), so we never command more than the
// device will actually deliver. Caps the discharge setpoint.
// Runtime (not persisted).
int mforce = 0; // 0 none, 1 force-charge, 2 force-discharge (manual, transient)
int mk_setp = 0; // current power setpoint: + = discharge, - = charge, 0 = idle
int l_sur = 0; // last surplus (W)
int l_def = 0; // last deficit (W)
int l_pw = -1; // last PW SOC
int data_ok = 0; // PW globals seen yet?
float sur_f = 0.0; // EMA-filtered surplus (W) — anti-oscillation
float def_f = 0.0; // EMA-filtered deficit (W)
float relief_wh = 0.0; // energy the Venus E handled that the PW would have (Wh, since boot)
// web button flags + persist dirty tracking
int btn_chg = 0; int btn_dis = 0; int btn_stop = 0;
int last_max = -1; int last_min = -1; int dirty = 0;
#ifdef MK_REAL
// ─── LIVE control of a real Venus E over its local UDP JSON-RPC API ──────────
// Marstek "local API" (enable it in the Marstek app): JSON-RPC over UDP on port
// 30000, FW >= V144. Verified live on a VenusE 3.0 (ver 148):
// read : ES.GetStatus -> bat_soc / ongrid_power / pv_power ...
// write : ES.SetMode "Passive" -> passive_cfg{power, cd_time}
// TinyC speaks it natively (udp(6)=send to ip:port, udp(0)/udp(1)=listen/read),
// so there is NO Modbus, NO Python bridge and NO firmware change.
char mk_ip[] = "192.168.188.182"; // Venus E IP (DHCP — pin a lease in the router!)
#define MK_PORT 30000 // Marstek local-API UDP port
#define MK_LPORT 30000 // local UDP port we bind to receive the reply
#define MK_CD (TICK_S * 3) // passive_cfg countdown (s): the battery auto-
// reverts to idle if we stop pushing -> dead-man safety.
// Always > TICK_S so a normal tick re-arms it in time.
char mk_req[288]; // SetMode request scratch (sprintf target)
char mk_rsp[512]; // response scratch
int mk_udp_open = 0; // listen port opened yet?
int mk_ongrid = 0; // last ongrid_power read (W) — operational log
int mk_wr_ok = 0; // did the last SetMode get acked?
// Fixed ES.GetStatus request (no per-call formatting needed).
char mk_get[] = "{\"id\":1,\"method\":\"ES.GetStatus\",\"params\":{\"id\":0}}";
// Pull an integer value for "key" out of a JSON reply (skips the ": <ws>" gap;
// the Venus pretty-prints with tabs). Returns -999999 if the key is absent.
int mk_json_int(char src[], char key[]) {
int p = strFind(src, key);
if (p < 0) { return -999999; }
int len = strlen(src);
p = p + strlen(key);
while (p < len) {
int c = src[p];
if (c == 45 || (c >= 48 && c <= 57)) { break; } // '-' or 0..9
p = p + 1;
}
char num[16];
strSub(num, src, p, 12);
return atoi(num);
}
// One request -> response round-trip; the reply lands in mk_rsp. Returns its
// length (0 = no reply this tick, device busy/offline -> caller retries).
int mk_xfer(char req[]) {
// NEVER touch the socket before the network is FULLY up. A udp() open/send/recv
// before the link is ready corrupts the heap -> tlsf_free/lwIP crash + boot-loop
// on an autoexec device. Gate on BOTH tasm_wifi (associated) AND tasm_net (IP
// obtained): WiFi-up-but-no-IP-yet (the post-reboot window) still wedged the
// TaskLoop, which stopped tick() -> msoc broadcast silently died until a manual
// restart. Return 0 = "no reply this tick"; the caller retries next tick.
if (!tasm_wifi || !tasm_net) { return 0; }
if (!mk_udp_open) { udp(0, MK_LPORT); mk_udp_open = 1; }
udp(6, mk_ip, MK_PORT, req);
int n = 0; int i = 0;
while (i < 20) { delay(40); n = udp(1, mk_rsp); if (n > 0) { i = 99; } i = i + 1; }
return n; // up to ~800 ms for a reply, then give up
}
// Read REAL SOC (bat_soc %) -> mk_soc; integrate REAL grid power into the relief
// metric. Replaces the simulated SOC integration when -DMK_REAL.
void mk_read_real() {
if (mk_xfer(mk_get) <= 0) { return; } // offline this tick — retry next
int soc = mk_json_int(mk_rsp, "bat_soc");
if (soc != -999999) { mk_soc = (float)soc; }
int og = mk_json_int(mk_rsp, "ongrid_power"); // grid-tied power (W)
if (og != -999999) {
mk_ongrid = og;
float ap = (float)og; if (ap < 0.0) { ap = -ap; }
relief_wh = relief_wh + ap * (float)TICK_S / 3600.0;
}
}
// Push the setpoint via Passive mode: setp<0 charge, setp>0 discharge, 0 idle.
// The Marstek power sign convention matches mk_setp exactly (neg=charge), so it
// maps straight through. cd_time auto-reverts to idle if we ever stop pushing.
// The device drops ~half its SetMode *responses* (reads are reliable, writes are
// flaky), so retry until we see "set_result" in the reply — typically lands by
// try 2. Runs in the TaskLoop worker, so the per-try waits never stall the main
// loop. If all tries miss, the previous setpoint simply holds (cd_time spans 3
// ticks), and the next tick tries again.
void mk_write_real(int setp) {
sprintf(mk_req,
"{\"id\":1,\"method\":\"ES.SetMode\",\"params\":{\"id\":0,\"config\":{\"mode\":\"Passive\",\"passive_cfg\":{\"power\":%d,\"cd_time\":%d}}}}",
setp, MK_CD);
mk_wr_ok = 0;
int k = 0;
while (k < 4) {
int n = mk_xfer(mk_req);
if (n > 0 && strFind(mk_rsp, "set_result") >= 0) { mk_wr_ok = 1; k = 99; } // acked
else { k = k + 1; delay(200); }
}
}
#endif
// ─── Control decision — runs every TICK_S seconds ────────────────────────────
void decide() {
int sol = (int)sop; int hou = (int)hip; int pw = (int)pwl;
l_pw = pw;
// PV vs house balance. Self-compensate so our OWN charge/discharge doesn't
// pollute the reading: charging raises hip, discharging lowers it, so the
// metered hip already carries -mk_setp. Removing it (net -= mk_setp) breaks
// the feedback loop that fed the fight with the Powerwall.
int net = sol - hou;
#ifdef MK_SELF_COMP
net = net - mk_setp;
#endif
int surplus = net; if (surplus < 0) { surplus = 0; }
int deficit = -net; if (deficit < 0) { deficit = 0; }
l_sur = surplus; l_def = deficit;
// Data sanity: all-zero means powerwall.tc hasn't published yet.
data_ok = (sol != 0 || hou != 0 || pw != 0);
// EMA-filter the balance so the Powerwall's fast transients are averaged out.
sur_f = sur_f + ((float)surplus - sur_f) / (float)FILT_N;
def_f = def_f + ((float)deficit - def_f) / (float)FILT_N;
int fsur = (int)sur_f; int fdef = (int)def_f;
int soc = (int)mk_soc;
// Manual force overrides AUTO — immediate full power (still respects SOC limits).
if (mforce == 1) { // force charge
if (soc < mk_max) { mk_setp = -MK_PMAX; } else { mforce = 0; mk_setp = 0; }
} else if (mforce == 2) { // force discharge
if (soc > mk_min) { mk_setp = mk_dcap; } else { mforce = 0; mk_setp = 0; } // cap = app limit
} else {
// AUTO: target from the FILTERED balance, then slew-rate limit toward it.
int target = 0;
if (armed && data_ok) {
// Don't charge the Marstek from surplus until the Powerwall SOC has reached
// mk_pwlmin — when both are low, fill the Powerwall first. (Discharge to
// relieve the PW is NOT gated; force-charge overrides this too.)
if (fsur > DB && soc < mk_max && pw >= mk_pwlmin) {
target = fsur; if (target > MK_PMAX) { target = MK_PMAX; }
target = -target; // negative = charging
} else if (fdef > DB && soc > mk_min) {
target = fdef; if (target > mk_dcap) { target = mk_dcap; } // app 800/2500 discharge limit
}
}
int step = target - mk_setp; // move slow enough that the fast PW
if (step > RAMP) { step = RAMP; } // settles between our steps -> no fight
if (step < -RAMP) { step = -RAMP; }
mk_setp = mk_setp + step;
}
}
// ─── Battery model — integrate SOC + relief metric from the setpoint ──────────
void integrate() {
#ifdef MK_REAL
// Real device: SOC was refreshed from the battery in tick(); just push the
// setpoint and let the Venus E perform the actual charge/discharge.
mk_write_real(mk_setp);
#else
// SIMULATED battery — integrate SOC + relief from the setpoint.
// Wh moved this tick. dt_h = TICK_S/3600.
// charge: energy stored = P * dt * eff -> SOC up
// discharge: energy drawn = P * dt / eff -> SOC down
if (mk_setp < 0) {
float p = (float)(-mk_setp);
float wh = p * (float)TICK_S / 3600.0 * (float)MK_EFF / 100.0;
mk_soc = mk_soc + wh / (float)MK_CAP_WH * 100.0;
relief_wh = relief_wh + p * (float)TICK_S / 3600.0;
} else if (mk_setp > 0) {
float p = (float)mk_setp;
float wh = p * (float)TICK_S / 3600.0 * 100.0 / (float)MK_EFF;
mk_soc = mk_soc - wh / (float)MK_CAP_WH * 100.0;
relief_wh = relief_wh + p * (float)TICK_S / 3600.0;
}
if (mk_soc > 100.0) { mk_soc = 100.0; }
if (mk_soc < 0.0) { mk_soc = 0.0; }
#endif
}
void tick() {
#ifdef MK_REAL
mk_read_real(); // refresh REAL SOC (+ relief) from the battery before deciding
#endif
decide();
integrate();
msoc = mk_soc; // broadcast current Venus SOC to the fleet (every TICK_S)
#ifdef MK_REAL
addLog("MK: soc=%.1f%% setp=%dW ongrid=%dW wr=%d sur=%d def=%d pw=%d%% mode=%s",
mk_soc, mk_setp, mk_ongrid, mk_wr_ok, l_sur, l_def, l_pw,
(mforce ? "FORCE" : (armed ? "AUTO" : "OFF")));
#else
addLog("MK: soc=%.1f%% setp=%dW sur=%d def=%d pw=%d%% mode=%s",
mk_soc, mk_setp, l_sur, l_def, l_pw,
(mforce ? "FORCE" : (armed ? "AUTO" : "OFF")));
#endif
}
void pollButtons() {
if (btn_chg) { btn_chg = 0; mforce = 1; addLog("MK: force charge"); }
if (btn_dis) { btn_dis = 0; mforce = 2; addLog("MK: force discharge"); }
if (btn_stop) { btn_stop = 0; mforce = 0; armed = 0; dirty = 1; addLog("MK: STOP"); }
if (mk_max != last_max || mk_min != last_min) { dirty = 1; last_max = mk_max; last_min = mk_min; }
if (dirty) { dirty = 0; saveVars(); }
}
void TaskLoop() {
delay(5000);
int c = 0;
while (1) {
pollButtons();
c = c + 1;
if (c >= TICK_S) { c = 0; tick(); }
delay(1000);
}
}
void WebCall() {
char b[160];
char st[40];
if (mforce == 1) { strcpy(st, "⚡ Laden (erzwungen)"); }
else if (mforce == 2) { strcpy(st, "⚡ Entladen (erzwungen)"); }
else if (mk_setp < 0) { strcpy(st, "🔌 Laden (PV-Überschuss)"); }
else if (mk_setp > 0) { strcpy(st, "🔋 Entladen (entlastet PW)"); }
else if (armed) { strcpy(st, "AUTO bereit"); }
else { strcpy(st, "aus"); }
#ifdef MK_REAL
webSend("<tr><td colspan=2 style='text-align:center;background:#0a3a0a;color:#6f6;padding:4px;border-radius:6px'>🔌 LIVE · Marstek Venus E (Local API / UDP)</td></tr>");
#else
webSend("<tr><td colspan=2 style='text-align:center;background:#3a2a00;color:#fc6;padding:4px;border-radius:6px'>🧪 SIMULATION · Marstek Venus E (keine echte Batterie)</td></tr>");
#endif
sprintf(b, "{s}Status{m}%s{e}", st); webSend(b);
sprintf(b, "{s}🔋 Venus-E SOC{m}%.1f %% · %.2f kWh{e}",
mk_soc, mk_soc / 100.0 * (float)MK_CAP_WH / 1000.0); webSend(b);
sprintf(b, "{s}⚡ Setpoint{m}%d W{e}", mk_setp); webSend(b);
sprintf(b, "{s}☀ Solar / Haus{m}%d / %d W{e}", (int)sop, (int)hip); webSend(b);
sprintf(b, "{s}⏻ Überschuss / Defizit{m}%d / %d W{e}", l_sur, l_def); webSend(b);
sprintf(b, "{s}🔋 Powerwall{m}%d %%{e}", l_pw); webSend(b);
sprintf(b, "{s}♻ PW entlastet (seit Start){m}%.2f kWh{e}", relief_wh / 1000.0); webSend(b);
sprintf(b, "{s}Regeln{m}max %d%% · Reserve %d%% · Laden ab PW %d%% · Entladen max %d W{e}", mk_max, mk_min, mk_pwlmin, mk_dcap); webSend(b);
if (!data_ok) { webSend("{s}{m}<span style='color:#f88'>warte auf Powerwall-Daten…</span>{e}"); }
// Controls ON THE MAIN PAGE — same pattern as wallbox_charge: raw HTML calling
// /cm?cmnd=MK ... -> the MK Command() handler, so they sit right inside this
// status card (the webButton/webSlider widgets in WebUI() are the /tc_ui page).
// State shows in the rows above; sliders pause the page refresh while dragging
// (clearTimeout lt/ft) and send on release, then la() refreshes.
webSend("<div style='display:flex;gap:4px;margin:6px 0'>");
webSend("<button onclick=\"fetch('/cm?cmnd=MK%20a')\">AUTO</button>");
webSend("<button class='bred' onclick=\"fetch('/cm?cmnd=MK%20c')\">⚡ Laden</button>");
webSend("<button onclick=\"fetch('/cm?cmnd=MK%20d')\">⚡ Entladen</button>");
webSend("<button style='background:#666' onclick=\"fetch('/cm?cmnd=MK%20s')\">STOP</button>");
webSend("</div>");
char sb[360];
sprintf(sb, "<div style='margin:4px 2px'>Lade-Limit <b>%d%%</b><input type='range' min='50' max='100' value='%d' onmousedown='clearTimeout(lt);clearTimeout(ft)' ontouchstart='clearTimeout(lt);clearTimeout(ft)' onchange=\"fetch('/cm?cmnd=MK%%20ma%%20'+this.value);la()\"></div>", mk_max, mk_max);
webSend(sb);
sprintf(sb, "<div style='margin:4px 2px'>Backup-Reserve <b>%d%%</b><input type='range' min='5' max='60' value='%d' onmousedown='clearTimeout(lt);clearTimeout(ft)' ontouchstart='clearTimeout(lt);clearTimeout(ft)' onchange=\"fetch('/cm?cmnd=MK%%20mi%%20'+this.value);la()\"></div>", mk_min, mk_min);
webSend(sb);
sprintf(sb, "<div style='margin:4px 2px'>Marstek laden ab PW <b>%d%%</b><input type='range' min='5' max='100' value='%d' onmousedown='clearTimeout(lt);clearTimeout(ft)' ontouchstart='clearTimeout(lt);clearTimeout(ft)' onchange=\"fetch('/cm?cmnd=MK%%20pw%%20'+this.value);la()\"></div>", mk_pwlmin, mk_pwlmin);
webSend(sb);
sprintf(sb, "<div style='margin:4px 2px'>Entlade-Limit (App-Wert) <b>%d W</b><input type='range' min='800' max='2500' step='100' value='%d' onmousedown='clearTimeout(lt);clearTimeout(ft)' ontouchstart='clearTimeout(lt);clearTimeout(ft)' onchange=\"fetch('/cm?cmnd=MK%%20dc%%20'+this.value);la()\"></div>", mk_dcap, mk_dcap);
webSend(sb);
}
// No WebUI()/tc_ui page — all controls are inline on the main page (WebCall), so the
// firmware shows no "TinyC UI" button for this slot.
void Command(char cmd[]) {
int i = 0; while (cmd[i] == ' ') { i = i + 1; }
if (cmd[i] == 'a') { armed = 1 - armed; dirty = 1; pollButtons(); responseCmnd("AUTO toggled"); return; }
if (cmd[i] == 's' && cmd[i + 1] != 'o') { btn_stop = 1; responseCmnd("STOP"); return; }
if (cmd[i] == 'c') { btn_chg = 1; responseCmnd("force charge"); return; }
if (cmd[i] == 'd' && cmd[i + 1] != 'c') { btn_dis = 1; responseCmnd("force discharge"); return; }
if (cmd[i] == 'm' && cmd[i + 1] == 'a') { char nb[8]; strSub(nb, cmd, i + 3, 5); int v = atoi(nb);
if (v >= 50 && v <= 100) { mk_max = v; dirty = 1; } responseCmnd("max set"); return; }
if (cmd[i] == 'm' && cmd[i + 1] == 'i') { char nb[8]; strSub(nb, cmd, i + 3, 5); int v = atoi(nb);
if (v >= 5 && v <= 60) { mk_min = v; dirty = 1; } responseCmnd("min set"); return; }
if (cmd[i] == 's' && cmd[i + 1] == 'o') { char nb[8]; strSub(nb, cmd, i + 3, 5); int v = atoi(nb);
if (v >= 0 && v <= 100) { mk_soc = (float)v; saveVars(); } responseCmnd("sim soc set"); return; }
if (cmd[i] == 'p' && cmd[i + 1] == 'w') { char nb[8]; strSub(nb, cmd, i + 3, 5); int v = atoi(nb);
if (v >= 5 && v <= 100) { mk_pwlmin = v; dirty = 1; } responseCmnd("pw-min set"); return; }
if (cmd[i] == 'd' && cmd[i + 1] == 'c') { char nb[8]; strSub(nb, cmd, i + 3, 6); int v = atoi(nb);
if (v >= 800 && v <= 2500) { mk_dcap = v; dirty = 1; } responseCmnd("discharge cap set"); return; }
char r[160];
sprintf(r, "soc=%.1f setp=%d armed=%d force=%d max=%d min=%d pwmin=%d dcap=%d sur=%d def=%d pw=%d",
mk_soc, mk_setp, armed, mforce, mk_max, mk_min, mk_pwlmin, mk_dcap, l_sur, l_def, l_pw);
responseCmnd(r);
}
int main() {
addCommand("MK");
// persist vars load 0 on first run — seed sane defaults once.
if (seeded == 0) {
#ifdef MK_REAL
armed = 0; // real battery: start with AUTO OFF — flip it on (MK a) after
// watching it read the live SOC, so control is a conscious step
#else
armed = 1;
#endif
mk_max = 95; mk_min = 20; mk_soc = 50.0; seeded = 1;
saveVars();
}
if (mk_max < 50 || mk_max > 100) { mk_max = 95; }
if (mk_min < 5 || mk_min > 60) { mk_min = 20; }
// New persist slot loads as 0 on an already-running device -> clamp gives it the
// default; the slider then persists a chosen value in [5,100].
if (mk_pwlmin < 5 || mk_pwlmin > 100) { mk_pwlmin = 30; }
if (mk_dcap < 800 || mk_dcap > 2500) { mk_dcap = 2500; } // new slot loads 0 -> default; app min = 800
last_max = mk_max; last_min = mk_min;
addLog("marstek_sim ready (soc=%.1f max=%d min=%d pwmin=%d dcap=%d armed=%d)", mk_soc, mk_max, mk_min, mk_pwlmin, mk_dcap, armed);
return 0;
}