Skip to content

ecotracker.tc

EcoTracker Emulator — Marstek, Hoymiles, Jackery, NOAH

Source on GitHub

// EcoTracker Emulator — Marstek, Hoymiles, Jackery, NOAH
// Drop-in replacement for ottelo's Scripter EcoTrackerEmuSimple, ported to
// TinyC. Emulates an Everhome EcoTracker over mDNS + REST API /v1/json so
// PV-Akku / Speicher firmwares (Marstek Jupiter / Venus / B2500, Hoymiles
// MS-A2, Jackery Homepower 2000 Ultra, Growatt NOAH 2000, …) can pull live
// meter data from a Tasmota SML lesekopf.
//
// Requires: USE_SML (or USE_SML_M) on the device + a valid /sml_meter.def
// (load via the IDE's SML tab, or pre-flash).
//
// Implementation note — Jackery compatibility:
//   The Jackery Homepower 2000 Ultra (and likely NOAH 2000) require the
//   EcoTracker's TCP connection to stay open across poll cycles. The
//   default Arduino-ESP32 WebServer closes after every response, which
//   breaks Jackery within seconds. This handler uses webRawMode() +
//   webRawWrite() + webKeepAlive() (TinyC v1.7+) to:
//     1. Skip Tasmota's auto-injected text/html + chunked + Connection: close
//     2. Write the exact 3-header response a physical EcoTracker sends
//        (HTTP/1.1 200 OK + Content-Type + Content-Length, nothing else)
//     3. Keep the TCP socket open for the next poll
//   Marstek / Hoymiles / B2500 don't strictly need this — but it's the
//   correct response shape anyway, so we ship one handler for all.

// ── Persistent state (auto-saved to /<file>.pvs) ──
persist float dval;     // daily counter in (kWh) at midnight
persist float dval2;    // daily counter out (kWh) at midnight
persist float mval;     // monthly counter in
persist float mval2;    // monthly counter out
persist float yval;     // yearly counter in
persist float yval2;    // yearly counter out
persist int pwroffset;  // power offset [W] for zero-feed regulation

// ── Runtime state ──
float cpwr;          // current smoothed power
int   once;          // mDNS registered flag
int   last_hr;       // last seen hour (for midnight detect)
int   req_count;     // total /v1/json responses sent (diagnostic)
char  buf[160];      // WebCall sensor row scratch (one row at a time)
char  hdr[200];      // HTTP response header scratch for WebOn
char  json[256];     // HTTP response body scratch for WebOn

// ── Settings (WebUI widgets) ──
int do_init;         // button: initialize counters from meter
int do_save;         // button: manually save data

void initVars() {
    float ein  = smlGet(2);
    float eout = smlGet(3);
    dval = ein;  dval2 = eout;
    mval = ein;  mval2 = eout;
    yval = ein;  yval2 = eout;
    saveVars();
    addLog("EcoTracker: counters initialized from meter");
}

// ── EcoTracker REST API handler ──────────────────────────────
// Builds the response by hand and writes it raw to the TCP socket, then
// flags the connection as keep-alive so Jackery (and similar firmwares
// that pipeline on the EcoTracker port) can keep polling on the same
// connection. The 3-header shape mirrors what a physical EcoTracker
// sends (verified via curl in sdeigm/uni-meter#265 — no Server, no Date,
// no Connection field).
void WebOn() {
    if (webHandler() != 1) return;
    webRawMode();   // suppress Tasmota's auto WSContentBegin + chunked + CT_HTML

    float ein  = smlGet(2) * 1000.0;   // kWh → Wh (EcoTracker convention)
    float eout = smlGet(3) * 1000.0;
    sprintf(json,
        "{\"power\":%.0f,\"powerAvg\":%.0f,\"energyCounterIn\":%.0f,\"energyCounterOut\":%.0f}",
        cpwr, cpwr, ein, eout);

    int blen = strlen(json);
    sprintf(hdr,
        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n",
        blen);

    webRawWrite(hdr);
    webRawWrite(json);
    webKeepAlive();        // socket stays open for next poll
    req_count = req_count + 1;
}

// ── Every second: register mDNS (once WLAN is up), then smoothing + midnight ──
void EverySecond() {
    // Advertise the EcoTracker over mDNS as soon as the NETWORK (WLAN) is up —
    // gated on `tasm_wifi` (1 = up), NOT on NTP time or a live power reading.
    // A PV battery (NOAH / Jackery / Marstek …) must DISCOVER the meter via mDNS
    // *before* any data flows, and 0 W is a perfectly valid reading. The old
    // `tasm_year < 2020` + `pwr == 0` guards meant that on a bench with simulated
    // SML data and/or no internet (NTP never syncs) the service was never
    // announced — hence "der NOAH findet den Ecotracker nicht mal". We never
    // touch the mDNS/network stack before tasm_wifi, and retry each second until
    // mdnsRegister() returns 0 (MDNS.begin succeeds).
    if (once == 0) {
        if (tasm_wifi == 0) return;                  // WLAN not up yet — wait
        if (mdnsRegister("ecotracker-", "-", "everhome") == 0) {
            once = 1;
            addLog("EcoTracker: mDNS registered");
        }
        return;                                      // retry next second if needed
    }

    if (do_init) { initVars(); do_init = 0; }
    if (do_save) { saveVars(); do_save = 0; }

    float pwr = smlGet(1);
    if (pwr == 0.0) return;   // no meter data yet → keep serving the last cpwr

    // Power smoothing — halve peaks above 500 W so the battery doesn't
    // chase spikes. Same heuristic as ottelo's Scripter version.
    float diff = pwr - cpwr;
    if (diff > 500.0) {
        cpwr = pwr - (diff / 2.0);
    } else {
        cpwr = pwr + (float)pwroffset;
    }

    // Midnight rollover — needs a valid wall clock, so keep this NTP-gated.
    if (tasm_year >= 2020) {
        int hr = tasm_hour;
        if (hr == 0 && last_hr != 0) {
            dval  = smlGet(2);
            dval2 = smlGet(3);
            if (tasm_day == 1) {
                mval  = smlGet(2);
                mval2 = smlGet(3);
            }
            if (tasm_day == 1 && tasm_month == 1) {
                yval  = smlGet(2);
                yval2 = smlGet(3);
            }
            saveVars();
        }
        last_hr = hr;
    }
}

// ── Web display: sensor rows on Tasmota main page ──
void WebCall() {
    sprintf(buf, "{s}Leistung (an PV-Akku){m}%.0f W{e}", cpwr);
    webSend(buf);

    float ein  = smlGet(2);
    float eout = smlGet(3);

    sprintf(buf, "{s}Tagesverbrauch{m}%.2f kWh{e}",   ein  - dval);  webSend(buf);
    sprintf(buf, "{s}Monatsverbrauch{m}%.2f kWh{e}",  ein  - mval);  webSend(buf);
    sprintf(buf, "{s}Jahresverbrauch{m}%.2f kWh{e}",  ein  - yval);  webSend(buf);
    sprintf(buf, "{s}Tageseinspeisung{m}%.2f kWh{e}", eout - dval2); webSend(buf);
    sprintf(buf, "{s}Monatseinspeisung{m}%.2f kWh{e}",eout - mval2); webSend(buf);
    sprintf(buf, "{s}Jahreseinspeisung{m}%.2f kWh{e}",eout - yval2); webSend(buf);
    sprintf(buf, "{s}EcoTracker Polls{m}%d{e}", req_count); webSend(buf);
    sprintf(buf, "{s}Uptime{m}%d min{e}", tasm_uptime / 60);
    webSend(buf);
}

// ── Settings page (WebUI widgets) ──
void WebUI() {
    int page = webPage();
    if (page == 0) {
        webNumber(pwroffset, -100, 100, "Offset [W] Nulleinspeisung");
        webButton(do_init, "Zaehler initialisieren|initialisiert");
        webButton(do_save, "Daten speichern|gespeichert");
    }
}

// ── MQTT telemetry ──
void JsonCall() {
    float ein  = smlGet(2);
    float eout = smlGet(3);
    sprintf(buf,
        ",\"EcoTracker\":{\"Power\":%.0f,\"EnergyIn\":%.3f,\"EnergyOut\":%.3f,\"DayIn\":%.3f,\"DayOut\":%.3f,\"Polls\":%d}",
        cpwr, ein, eout, ein - dval, eout - dval2, req_count);
    responseAppend(buf);
}

// ── Main: register endpoint ──
int main() {
    once      = 0;
    last_hr   = -1;
    cpwr      = 0.0;
    req_count = 0;
    do_init   = 0;
    do_save   = 0;

    webPageLabel(0, "EcoTracker Einstellungen");

    // Register /v1/json endpoint. Handler runs in raw + keep-alive mode
    // (see WebOn above) — works with both Jackery-style firmwares and
    // standard close-after-response clients.
    webOn(1, "/v1/json");

    // First-boot counter init from current meter reading.
    if (dval == 0.0 && smlGet(2) > 0.0) {
        initVars();
    }

    tasmCmd("Backlog2 Timezone 99;TimeStd 0,0,10,1,3,60;TimeDst 0,0,3,1,2,120", buf);

    addLog("EcoTracker: started");
    return 0;
}