Skip to content

marstek_venus.tc

marstek_venus.tc — Marstek Venus E (5.12 kWh) surplus/reserve controller.

Source on GitHub

// 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, "&#9889; Laden (erzwungen)"); }
    else if (mforce == 2) { strcpy(st, "&#9889; Entladen (erzwungen)"); }
    else if (mk_setp < 0) { strcpy(st, "&#128268; Laden (PV-&Uuml;berschuss)"); }
    else if (mk_setp > 0) { strcpy(st, "&#128267; 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'>&#128268; LIVE &middot; 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'>&#129514; SIMULATION &middot; Marstek Venus E (keine echte Batterie)</td></tr>");
#endif
    sprintf(b, "{s}Status{m}%s{e}", st);                                            webSend(b);
    sprintf(b, "{s}&#128267; Venus-E SOC{m}%.1f %% &middot; %.2f kWh{e}",
            mk_soc, mk_soc / 100.0 * (float)MK_CAP_WH / 1000.0);                    webSend(b);
    sprintf(b, "{s}&#9889; Setpoint{m}%d W{e}", mk_setp);                           webSend(b);
    sprintf(b, "{s}&#9728; Solar / Haus{m}%d / %d W{e}", (int)sop, (int)hip);       webSend(b);
    sprintf(b, "{s}&#9211; &Uuml;berschuss / Defizit{m}%d / %d W{e}", l_sur, l_def); webSend(b);
    sprintf(b, "{s}&#128267; Powerwall{m}%d %%{e}", l_pw);                          webSend(b);
    sprintf(b, "{s}&#9851; PW entlastet (seit Start){m}%.2f kWh{e}", relief_wh / 1000.0); webSend(b);
    sprintf(b, "{s}Regeln{m}max %d%% &middot; Reserve %d%% &middot; Laden ab PW %d%% &middot; 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&hellip;</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')\">&#9889; Laden</button>");
    webSend("<button onclick=\"fetch('/cm?cmnd=MK%20d')\">&#9889; 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;
}