ecotracker.tc¶
EcoTracker Emulator — Marstek, Hoymiles, Jackery, NOAH
// 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;
}