Zum Inhalt

growatt.tc

growatt.tc — gemu's Growatt-inverter Modbus reader + feed-in control

Source on GitHub

// ============================================================================
// growatt.tc — gemu's Growatt-inverter Modbus reader + feed-in control
//              (growatt_garten, .63)
// ============================================================================
//
// Port of growatt_garten.tas. Two parts:
//   1. SML/Modbus reading — the GRW descriptor + pin management via
//      sml_descriptor.tc (same pattern as deye.tc). 7 Growatt values publish
//      in the standard SML sensor block.
//   2. Feed-in control — write the Growatt active-power-rate register 0x0003:
//        • Manual limit slider (gl, %): on change → reg 0x0003 = gl directly
//          (0..100 %; 100 % = full feed-in, now permitted by German law).
//        • Auto-throttle (Scripter #setlimit): when the house meter (zwzc)
//          reports a grid feed-in above `limit`, drop the inverter to
//          (mainsw - excess)/42 %. Edge-triggered, armed by the g_auto
//          checkbox (a kill switch for autonomous inverter writes). Every
//          write is capped at mproz % (inverter safety ceiling).
//
// The Modbus write telegram "r0106000300<proz>" is byte-identical to the
// Scripter sml(1 3 "r0106000300"+hn(proz)): the SML driver sees the 'r' raw
// prefix on an 'm' meter → appends only the Modbus-CRC16 (no read-count).
//
// AUTO-THROTTLE IS DORMANT while the (new) house meter reports zwzc=0: the
// overflow branch (zwzc<0 && |zwzc|>limit) can never be reached, so no
// autonomous write happens until the meter starts exporting zwzc again.
//
// The GRW descriptor is custom (NOT on the ottelo repo), so /growatt_backup.def
// holds a copy and the restore button rebuilds /sml_meter.def from it.
// ============================================================================

#include "sml_descriptor.tc"

int do_reset;               // webButton → Sensor53 r
int do_restore;             // webButton → restore /sml_meter.def from /growatt_backup.def
char restore_buf[1024];     // own buffer (sml_buf is only 160 B)

// ── House meter (ZRZ / Zweirichtungszähler) — Scripter g:zwz* via UDP-multicast.
// `global` = the direct TinyC equivalent of Scripter `g:`: auto-updates by NAME
// from the house meter's broadcasts. READ-ONLY here (never assigned → never
// re-broadcast, so we don't fight the meter for ownership of the value).
global float zwzc;          // current grid power (W): - = feed-in, + = consumption
global float zwzi;          // total consumption (kWh)
global float zwzo;          // total feed-in (kWh)
float limit = 7250.0;       // grid feed-in limit (W) — Scripter's `limit`
char  zbuf[200];            // ZRZ web-tile scratch

// ── Growatt feed-in control ────────────────────────────────────────────────
persist watch int gl     = 100;   // manual limit slider (%), 0..100 → written straight to reg 0x0003
persist watch int g_auto = 0;     // auto-throttle armed (1) / off (0). OFF by default: German law now
                                  //   allows 100 % feed-in, so throttling is no longer required — we
                                  //   never reduce the inverter autonomously unless gemu arms this.
int  mproz       = 87;            // hard % ceiling for register 0x0003 (inverter safety max)
int  g_ovf;                       // current over-limit feed-in state (Scripter `ovf`)
int  g_ovf_prev;                  // edge detector for g_ovf (Scripter `chg[ovf]`)
int  g_last_proz = -1;            // last % written to reg 0x0003 (for tile/log; -1 = none yet)
char gcmd[40];                    // Modbus write-telegram scratch

// ── Phase 3: UDP global-array broadcast (Scripter `acp(xsml sml); gvrsa(xsml)`) ──
// Re-broadcasts the GRW readings as the multicast float array "xsml" (8 slots:
// [mainsv,mainsc,mainsw,s1w,s2w,limit,tot,spare]) so the house devices receive
// them by name. udpSendArray's wire format (=>xsml:[2-byte LE count][N×float])
// is byte-identical to Scripter gvrsa, and smlGet(i) mirrors Scripter sml[i].
persist watch int g_send = 1;     // broadcast on/off (0/1)
float gxml[8];                    // the 8-float array (mirrors Scripter M:xsml=0 8)
int   g_udp_tick;                 // 1 Hz divider → 2 s cadence (Scripter `upsecs%2==0`)
int   g_udp_cnt;                  // packets sent (tile counter)

// Write Growatt active-power-rate register 0x0003 = proz % (clamped 0..mproz).
// Telegram: r 01 06 0003 00 <proz>; driver appends Modbus-CRC16 (lo,hi).
void growatt_set_proz(int proz, int isauto) {
  if (proz > 100) proz = 100;       // hard register bound; the auto path pre-caps at mproz
  if (proz < 0)   proz = 0;
  sprintf(gcmd, "r0106000300%02X", proz);
  int rc = smlWrite(1, gcmd);
  g_last_proz = proz;
  if (isauto) { addLog("growatt AUTO: set %d%% (reg 0x0003) cmd=%s rc=%d", proz, gcmd, rc); }
  else        { addLog("growatt: limit -> %d%% (reg 0x0003) cmd=%s rc=%d", proz, gcmd, rc); }
}

// Scripter #setlimit: throttle so total grid feed-in returns to `limit`.
//   over   = |zwzc| - limit          (W above the feed-in limit)
//   pcurr  = mainsw - over           (target inverter output, W)
//   proz   = pcurr / 42  (capped)    (active-power-rate %)
void growatt_setlimit() {
  float over   = (-zwzc) - limit;
  float mainsw = smlGet(3);           // current Growatt output (W) = Scripter sml[3]
  float pcurr  = mainsw - over;
  if (pcurr > 0.0) {
    int proz = (int)(pcurr / 42.0);
    if (proz > mproz) { proz = mproz; }
    if (g_auto) {
      growatt_set_proz(proz, 1);
    } else {
      addLog("growatt AUTO(off): would set %d%% (zwzc=%.0f over=%.0f mainsw=%.0f)", proz, zwzc, over, mainsw);
    }
  }
}

// Copy /growatt_backup.def → /sml_meter.def, re-apply the chosen pins, reload.
void growatt_restore() {
  int h = fileOpen("/growatt_backup.def", 0);       // 0 = read
  if (h < 0) { addLog("growatt: /growatt_backup.def missing"); return; }
  int n = fileRead(h, restore_buf, 1020);
  fileClose(h);
  if (n <= 0) { addLog("growatt: backup empty"); return; }
  int w = fileOpen("/sml_meter.def", 1);            // 1 = write (truncate)
  if (w < 0) { addLog("growatt: cannot write /sml_meter.def"); return; }
  fileWrite(w, restore_buf, n);
  fileClose(w);
  if (sml_rx_pin >= 0 || sml_tx_pin >= 0) {         // re-substitute the current pins
    int fv = sml_filter ? 16 : 0;
    smlApplyPins("/sml_meter.def", sml_rx_pin, sml_tx_pin, fv);
  }
  tasmCmd("Sensor53 r", sml_buf);
  addLog("growatt: descriptor restored from /growatt_backup.def");
}

void EverySecond() {
  sml_descriptor_apply();                            // pins / filter / activ
  if (do_reset)   { tasmCmd("Sensor53 r", sml_buf); do_reset = 0; }
  if (do_restore) { growatt_restore();              do_restore = 0; }

  // ── Manual limit slider → write register 0x0003 (Scripter `if chg[gl]>0`) ──
  // gl is already a percent (0..100) → straight to the active-power-rate register.
  if (changed(gl)) {
    snapshot(gl);
    growatt_set_proz(gl, 0);
    saveVars();                                      // persist the new limit across reboot
  }
  // Persist the auto-throttle arm + UDP-broadcast toggles when they change.
  if (changed(g_auto)) { snapshot(g_auto); saveVars(); }
  if (changed(g_send)) { snapshot(g_send); saveVars(); }

  // ── Phase 3: broadcast the GRW readings as UDP global array "xsml" every 2 s ──
  // (Scripter `if upsecs%2==0 { acp(xsml sml); gvrsa(xsml) }`)
  if (g_send && sml_activ) {
    g_udp_tick = g_udp_tick + 1;
    if (g_udp_tick >= 2) {
      g_udp_tick = 0;
      smlCopy(gxml, 8);             // = Scripter acp(xsml sml): gxml[0..6]=SML vals 1..7
      gxml[7] = 0.0;                // spare (xsml[7]); smlCopy fills only the 7 GRW values
      udpSendArray("xsml", gxml, 8);
      g_udp_cnt = g_udp_cnt + 1;
    }
  }

  // ── Auto-throttle overflow detection (Scripter >S lines 40-60) ──
  // DORMANT while zwzc==0: the (-zwzc)>limit branch can never be reached.
  if (zwzc < 0.0 && (-zwzc) > limit) { g_ovf = 1; } else { g_ovf = 0; }
  if (g_ovf != g_ovf_prev) {                         // Scripter `if chg[ovf]>0`
    g_ovf_prev = g_ovf;
    if (g_ovf != 0) { growatt_setlimit(); }          // just entered over-limit → #setlimit
  }
}

// ── Main sensor page ──
// The SML driver publishes all 7 Growatt values in its own sensor block. Here
// we surface the off-state, the house-meter (ZRZ) tiles, and the current limit.
void WebCall() {
  if (!sml_activ) {
    webSend("{s}Growatt SML{m}aus (Rule1 off){e}");
    return;
  }
  // ── House-meter (ZRZ) tiles, from the UDP globals (Scripter >W section) ──
  sprintf(zbuf, "{s}ZRZ Verbrauch total{m}<b style='color:#c0392b'>%.1f kWh</b>{e}", zwzi);
  webSend(zbuf);
  sprintf(zbuf, "{s}ZRZ Einspeisung total{m}<b style='color:#27ae60'>%.1f kWh</b>{e}", zwzo);
  webSend(zbuf);
  if (zwzc < 0.0 && -zwzc > limit) {                  // feeding in over the limit → yellow
    sprintf(zbuf, "{s}ZRZ aktuell{m}<b style='color:#e0a800'>%.0f W (Grenze!)</b>{e}", zwzc);
  } else if (zwzc < 0.0) {                            // feeding in → green
    sprintf(zbuf, "{s}ZRZ aktuell{m}<b style='color:#27ae60'>%.0f W</b>{e}", zwzc);
  } else {                                            // consuming → red
    sprintf(zbuf, "{s}ZRZ aktuell{m}<b style='color:#c0392b'>%.0f W</b>{e}", zwzc);
  }
  webSend(zbuf);
  // ── Growatt limit setpoint + auto-throttle state ──
  sprintf(zbuf, "{s}Growatt Limit (Soll){m}<b>%d %%</b>{e}", gl);
  webSend(zbuf);
  if (g_auto) {
    webSend("{s}Auto-Drosselung{m}<b style='color:#27ae60'>aktiv</b>{e}");
  } else {
    webSend("{s}Auto-Drosselung{m}<b style='color:#888'>aus</b>{e}");
  }
  if (g_send) {
    sprintf(zbuf, "{s}UDP xsml{m}<b style='color:#27ae60'>aktiv (%d Pakete)</b>{e}", g_udp_cnt);
    webSend(zbuf);
  } else {
    webSend("{s}UDP xsml{m}<b style='color:#888'>aus</b>{e}");
  }
}

// ── Settings page (SML-Einstellungen menu button) ──
void WebUI() {
  sml_render_settings_panel();                       // SML aktiv + pins + repo + filter
  webSend("<hr><b>&#x2699;&#xFE0F; Growatt-Drosselung</b>");
  webSlider(gl, 0, 100, "Growatt Limit (%)");
  webCheckbox(g_auto, "Auto-Drosselung bei Netz-Einspeisung");
  webCheckbox(g_send, "GRW-Werte als UDP-Array \"xsml\" senden (alle 2s)");
  webSend("<div class='hint'>Schiebt das Limit (%) sofort in Register 0x0003 (Wirkleistungsbegrenzung). 100 % = volle Einspeisung (gesetzlich erlaubt). Die Auto-Drosselung (standardm&auml;ssig aus) greift nur, wenn der Hausz&auml;hler eine Einspeisung &uuml;ber der Grenze meldet (zwzc).</div>");
  webSend("<hr><b>&#x1F504; SML neu initialisieren</b>");
  webButton(do_reset, "Sensor53 r|SML neu geladen");
  webSend("<hr><b>&#x267B; Growatt-Descriptor</b>");
  webSend("<div class='hint'>Falls versehentlich ein Repo-Descriptor geladen wurde: stellt /sml_meter.def aus /growatt_backup.def wieder her, Pins bleiben.</div>");
  webButton(do_restore, "Growatt-Descriptor wiederherstellen|wiederhergestellt");
  webSend("<div style='text-align:center;font-size:10px;color:#777;margin-top:10px'><b>growatt.tc</b> &mdash; growatt_garten</div></div>");
}

int main() {
  if (sml_rx_pin == 0 && sml_tx_pin == 0) {          // first-run (fresh deploy: persist reset to 0)
    sml_rx_pin = -1;
    sml_tx_pin = -1;
    gl = 100;                                        // default the limit to full power (law-compliant)
    g_send = 1;                                      // default the UDP "xsml" broadcast on
  }
  if (sml_activ != tasm_rule) { tasm_rule = sml_activ; }
  webPageLabel(0, "SML-Einstellungen");
  smlScripterLoad("/sml_meter.def");
  addLog("growatt: started rx=%d tx=%d active=%d gl=%d auto=%d", sml_rx_pin, sml_tx_pin, sml_activ, gl, g_auto);
  return 0;
}