ecotracker_shelly_emu.tc¶
Unified PV-battery meter emulator — EcoTracker + Shelly Pro 3EM
// Unified PV-battery meter emulator — EcoTracker + Shelly Pro 3EM
// TinyC port of ottelo's *_EcoTrackerEmu / *_ShellyEmu Scripter scripts.
//
// Emulates a power meter that a PV-battery / storage reads for zero-feed-in
// (Nulleinspeisung) — works with any such battery (Marstek Venus/Jupiter/
// B2500, Hoymiles, Jackery, NOAH, …). It is NOT Marstek-specific; the name
// reflects the two METER dialects it speaks, not any one battery brand.
//
// ONE file, two builds via a compile-time toggle (replaces the former
// separate slim + chart scripts — they had silently diverged, which is
// exactly what caused ottelo's smlf=-1 / SIMULATE-on bugs; a single
// source can't drift):
//
// default (toggle off) — slim emulator, smallest bytecode, best for
// RAM-tight ESP8266. No charts.
// -DEMU_CHARTS — adds 4h/24h line charts + 31-day/12-month
// bar charts (Google Charts via WebChart),
// auto-persisted ring buffers, auto-reload.
//
// Emulates either:
// * Everhome EcoTracker (mDNS "ecotracker-<mac>", REST /v1/json)
// * Shelly Pro 3EM (mDNS "shellypro3em-<mac>", RPC, UDP 1010/2220)
// Mode is a persisted WebUI dropdown; a script restart applies the
// mDNS/UDP side-effects of a new mode (HTTP endpoints are always live).
//
// Requires: USE_SML (or USE_SML_M). SML descriptor loaded separately via
// the IDE SML tab or /sml_meter.def.
//
// NOTE: enabling/disabling EMU_CHARTS changes the persist layout, so
// the FNV-1a .pvs hash flips and persisted values (incl. chart history)
// reset once on the first boot of the new build — expected, harmless.
// ─── Build toggle ──────────────────────────────────────────────────────────
// Uncomment for the charts build (or pass -DEMU_CHARTS to tc_deploy).
//#define EMU_CHARTS
// ─── Simulator toggle (DEV ONLY — requires EMU_CHARTS) ─────────────────
// MUST stay commented out for any real deployment. When defined (charts
// build only): getSml() returns a fake triangle-wave instead of the real
// meter (the emulator would then feed fake power to the battery), chart
// sampling is accelerated to 1s/5s, and the rings are pre-seeded. Enable
// ONLY to eyeball chart rendering on hardware with no meter.
//#define SIMULATE 1
#ifdef EMU_CHARTS
// ─── Chart sizing ──────────────────────────────────────────────────────────
#define N4H 240 // 4h at 1-min raster (real mode)
#define N24H 288 // 24h at 5-min raster (real mode)
#define NDAY 31 // last 31 days
#define NMON 12 // last 12 months
#ifdef SIMULATE
#define SAMPLE_4H_SEC 1 // fast chart fill for testing
#define SAMPLE_24H_SEC 5
#else
#define SAMPLE_4H_SEC 60 // 1-min raster
#define SAMPLE_24H_SEC 300 // 5-min raster
#endif
#endif // EMU_CHARTS
// ─── Persistent settings (auto-saved to /tinyc_pvars.bin) ──────────────────
persist int emu_mode; // 0 = EcoTracker, 1 = Shelly Pro 3EM
persist int udp_port_sel; // 0 = 1010 (default), 1 = 2220 (B2500)
persist int pwroffset; // zero-feed offset [W]
persist int ctrlopt; // regulator optimization 0/1
persist int throttle; // update rate [s] (Shelly)
persist int pwrforce; // Shelly: push UDP even without request 0/1
#ifdef EMU_CHARTS
persist int chart_refresh; // auto-reload interval [s] (0 = off)
#endif
persist watch int meter_sel; // SML descriptor picker — index into ottelo's repo
persist watch int rx_pin; // SML serial RX (free-GPIO picker, -1 = unset)
persist watch int tx_pin; // SML serial TX (-1 = unset, single-direction meters)
persist float dval; // day baseline, energy in [kWh]
persist float dval2; // day baseline, energy out [kWh]
persist float mval; // month baseline in
persist float mval2; // month baseline out
persist float yval; // year baseline in
persist float yval2; // year baseline out
persist watch int sml_activ; // SML enable (mirrors tasm_rule)
persist watch int smlfo; // SML median filter enable (0 = off, 1 = on → smlf 0/16)
#ifdef EMU_CHARTS
persist watch int sndpwr; // MQTT: periodic power publish (0/1)
// ─── Chart ring buffers (auto-persisted) ───────────────────────────────────
persist float s4h[N4H]; // 4h power history [W]
persist float s24h[N24H]; // 24h power history [W]
persist float dcon[NDAY]; // daily consumption [kWh]
persist float dprod[NDAY]; // daily feed-in [kWh]
persist float mcon[NMON]; // monthly consumption [kWh]
persist float mprod[NMON]; // monthly feed-in [kWh]
persist int s4h_pos; persist int s4h_cnt;
persist int s24h_pos; persist int s24h_cnt;
persist int day_pos; persist int day_cnt;
persist int mon_pos; persist int mon_cnt;
#endif // EMU_CHARTS
// ─── Runtime state ─────────────────────────────────────────────────────────
float cpwr; // regulated power [W] (current)
float power3; // heavily smoothed power (EcoTracker powerAvg)
int once_mdns; // mDNS registered once
int once_udp; // UDP bound once
int last_hr; // midnight edge-detect
int udp_api_last; // 0=none, 1=EM.GetStatus, 2=Shelly.GetStatus
int udp_bind_port; // resolved UDP port used
#ifdef EMU_CHARTS
float power2; // light LPF for chart sampling
int mqtt_last; // last uptime when we published
#endif
char mac[32]; // own MAC, lowercase no colons
char rxbuf[256]; // UDP receive buffer
char body[640]; // HTTP/UDP response body (reused)
char wrap[768]; // Shelly UDP RPC envelope
char hdr[160]; // raw HTTP response header (EcoTracker keep-alive path)
#ifdef EMU_CHARTS
char pubbuf[96]; // MQTT publish command scratch
#endif
// ─── Transient UI flags ────────────────────────────────────────────────────
int do_init; // button: init counters from meter
int do_save; // button: manual save
#ifdef EMU_CHARTS
int do_init2; // button: reset chart buffers
#endif
// ─── Meter access (wraps smlGet so SIMULATE can inject fake data) ──────────
// In every non-SIMULATE build this is a thin pass-through to smlGet() —
// identical behaviour to calling smlGet() directly, one indirection.
#if defined(EMU_CHARTS) && defined(SIMULATE)
float sim_ein; // fake total energy consumed [kWh]
float sim_eout; // fake total energy fed-in [kWh]
float sim_pwr; // fake instantaneous power [W]
float getSml(int idx) {
if (idx == 1) return sim_pwr;
if (idx == 2) return sim_ein;
if (idx == 3) return sim_eout;
return 0.0;
}
// Advance simulator: triangle-wave power, integrate into counters.
void simTick() {
int p = tasm_uptime % 60;
float frac;
if (p < 30) {
frac = (float)p / 30.0;
} else {
frac = (float)(60 - p) / 30.0;
}
sim_pwr = -2000.0 + (frac * 5000.0);
if (sim_pwr > 0.0) {
sim_ein = sim_ein + sim_pwr / 3600000.0;
} else {
sim_eout = sim_eout + (0.0 - sim_pwr) / 3600000.0;
}
}
// Deterministic pseudo-random pre-fill of all four rings so the charts
// span their full window from the first page load.
void simSeedHistory() {
int i;
int rnd = tasm_uptime * 1103515245 + 12345;
for (i = 0; i < N4H; i = i + 1) {
rnd = rnd * 1103515245 + 12345;
float t = (float)i / (float)N4H * 6.2832;
float noise = (float)((rnd >> 8) & 0xFF) - 128.0;
s4h[i] = 500.0 + 2000.0 * (t - 3.1416) / 3.1416 * (t - 3.1416) / 3.1416 + noise * 1.5;
if (s4h[i] > 3000.0) s4h[i] = 3000.0;
if (s4h[i] < -2000.0) s4h[i] = -2000.0;
}
s4h_pos = 0;
s4h_cnt = N4H;
for (i = 0; i < N24H; i = i + 1) {
rnd = rnd * 1103515245 + 12345;
float t = (float)i / (float)N24H * 6.2832;
float noise = (float)((rnd >> 8) & 0xFF) - 128.0;
float morning = 2000.0 * (t - 1.5) * (t - 1.5);
float evening = 2500.0 * (t - 4.8) * (t - 4.8);
float hump = morning;
if (evening < hump) hump = evening;
s24h[i] = 2500.0 - hump + noise * 2.0;
if (s24h[i] > 3000.0) s24h[i] = 3000.0;
if (s24h[i] < -2000.0) s24h[i] = -2000.0;
}
s24h_pos = 0;
s24h_cnt = N24H;
for (i = 0; i < NDAY; i = i + 1) {
rnd = rnd * 1103515245 + 12345;
dcon[i] = 4.0 + (float)((rnd >> 8) & 0x3FFF) / 1170.0;
rnd = rnd * 1103515245 + 12345;
dprod[i] = (float)((rnd >> 8) & 0x3FFF) / 1365.0;
}
day_pos = 0;
day_cnt = NDAY;
for (i = 0; i < NMON; i = i + 1) {
rnd = rnd * 1103515245 + 12345;
mcon[i] = 150.0 + (float)((rnd >> 8) & 0x3FFF) / 65.5;
rnd = rnd * 1103515245 + 12345;
mprod[i] = 50.0 + (float)((rnd >> 8) & 0x3FFF) / 82.0;
}
mon_pos = 0;
mon_cnt = NMON;
}
#else
float getSml(int idx) {
return smlGet(idx);
}
#endif
// ─── Helpers ───────────────────────────────────────────────────────────────
void initVars() {
float ein = getSml(2);
float eout = getSml(3);
dval = ein; dval2 = eout;
mval = ein; mval2 = eout;
yval = ein; yval2 = eout;
saveVars();
addLog("MeterEmu: counters initialized from meter");
}
#ifdef EMU_CHARTS
void resetCharts() {
int i;
for (i = 0; i < N4H; i = i + 1) s4h[i] = 0.0;
for (i = 0; i < N24H; i = i + 1) s24h[i] = 0.0;
for (i = 0; i < NDAY; i = i + 1) { dcon[i] = 0.0; dprod[i] = 0.0; }
for (i = 0; i < NMON; i = i + 1) { mcon[i] = 0.0; mprod[i] = 0.0; }
s4h_pos = 0; s4h_cnt = 0;
s24h_pos = 0; s24h_cnt = 0;
day_pos = 0; day_cnt = 0;
mon_pos = 0; mon_cnt = 0;
saveVars();
addLog("MeterEmu: chart buffers cleared");
}
#endif
// Build EcoTracker /v1/json body into `body`
void buildEcoJson() {
float ein = getSml(2) * 1000.0; // kWh -> Wh
float eout = getSml(3) * 1000.0;
sprintf(body, "{\"power\":%.0f,\"powerAvg\":%.0f,\"agePower\":1000,\"powerPhase1\":%.0f,\"powerPhase2\":0,\"powerPhase3\":0,\"energyCounterIn\":%.0f,\"energyCounterOut\":%.0f}", cpwr, power3, cpwr, ein, eout);
}
void buildShellyEm() {
sprintf(body, "{\"id\":0,\"a_act_power\":%.0f,\"b_act_power\":0,\"c_act_power\":0,\"total_act_power\":%.0f}", cpwr, cpwr);
}
void buildShellyFull() {
float ein = getSml(2) * 1000.0;
float eout = getSml(3) * 1000.0;
sprintf(body, "{\"em:0\":{\"id\":0,\"a_act_power\":%.0f,\"b_act_power\":0,\"c_act_power\":0,\"total_act_power\":%.0f},\"emdata:0\":{\"id\":0,\"a_total_act_energy\":%.0f,\"a_total_act_ret_energy\":%.0f,\"b_total_act_energy\":0,\"b_total_act_ret_energy\":0,\"c_total_act_energy\":0,\"c_total_act_ret_energy\":0,\"total_act\":%.0f,\"total_act_ret\":%.0f}}", cpwr, cpwr, ein, eout, ein, eout);
}
void wrapShellyUdp() {
sprintf(wrap, "{\"id\":0,\"src\":\"shellypro3em-%s\",\"result\":%s}", mac, body);
}
// ─── HTTP endpoint handler ─────────────────────────────────────────────────
// /v1/json (EcoTracker) uses raw-mode + keep-alive: emits exactly the three
// headers a physical EcoTracker sends and keeps the TCP socket open across
// polls. Required by Jackery Homepower 2000 Ultra / Growatt NOAH 2000
// firmwares that reject Tasmota's chunked wrapper. See sdeigm/uni-meter#265.
void WebOn() {
int h = webHandler();
if (h == 1) { // /v1/json (EcoTracker)
webRawMode();
buildEcoJson();
int blen = strlen(body);
sprintf(hdr,
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n",
blen);
webRawWrite(hdr);
webRawWrite(body);
webKeepAlive();
} else if (h == 2) { // /rpc/EM.GetStatus (Shelly)
buildShellyEm();
webSend(body);
} else if (h == 3) { // /rpc/Shelly.GetStatus (Shelly)
buildShellyFull();
webSend(body);
}
}
// ─── Fast polling: UDP handling for Shelly mode ────────────────────────────
void Every100ms() {
if (emu_mode != 1 || once_udp == 0) return;
int n = udp(1, rxbuf);
int served = 0;
if (n > 0) {
if (strFind(rxbuf, "EM.GetStatus") >= 0) {
buildShellyEm();
wrapShellyUdp();
udp(2, wrap);
udp_api_last = 1;
served = 1;
} else if (strFind(rxbuf, "Shelly.GetStatus") >= 0) {
buildShellyFull();
wrapShellyUdp();
udp(2, wrap);
udp_api_last = 2;
served = 1;
}
}
if (served == 0 && pwrforce > 0 && udp_api_last > 0) {
if (udp_api_last == 1) {
buildShellyEm();
} else {
buildShellyFull();
}
wrapShellyUdp();
udp(2, wrap);
}
}
// ─── Per-second work ───────────────────────────────────────────────────────
void EverySecond() {
// Config-change handling runs BEFORE the NTP/meter guards — otherwise
// chicken-and-egg: no meter reading because pins not applied, pins not
// applied because the meter isn't reading.
int meter_changed = changed(meter_sel);
int pins_changed = changed(rx_pin) || changed(tx_pin);
int filter_changed = changed(smlfo);
int activ_changed = changed(sml_activ);
if (activ_changed) {
tasm_rule = sml_activ;
snapshot(sml_activ);
}
if (meter_changed || pins_changed || filter_changed || activ_changed) {
if (rx_pin >= 0 || tx_pin >= 0) {
// smlf (descriptor field 4 / ottelo's %0smlf%) follows the
// Median-Filter checkbox: 0 = off, 16 = median.
smlApplyPins("/sml_meter.def", rx_pin, tx_pin, smlfo ? 16 : 0);
}
snapshot(rx_pin);
snapshot(tx_pin);
snapshot(smlfo);
// Force SML to re-read the descriptor so new pins/filter/meter
// take effect without a reboot.
tasmCmd("Sensor53 r", body);
}
#ifdef EMU_CHARTS
// Persist MQTT-publish toggle across reboots
if (changed(sndpwr)) { snapshot(sndpwr); saveVars(); }
#endif
// (Re)compile the mini-scripter >F/>S sections on descriptor change
// (IEC 62056-21 mode-A wake-up meters encode their handshake there).
if (meter_changed) {
snapshot(meter_sel);
smlScripterLoad("/sml_meter.def");
}
// ── Discovery transport comes up as soon as the NETWORK (WLAN) is up,
// BEFORE NTP sync or any meter data. A PV battery (Jackery / NOAH /
// Marstek / …) must DISCOVER the meter via mDNS *before* data flows, and
// 0 W / 0 kWh is a perfectly valid reading. Gating discovery on a live
// meter or NTP was the "battery never finds the EcoTracker" bug — fixed
// the same way in ecotracker.tc. Only the smoothing/rollover work below
// stays behind the meter+NTP guards.
if (tasm_wifi == 1) {
// One-time mDNS registration (depends on persisted mode)
if (once_mdns == 0) {
if (emu_mode == 1) {
mdnsRegister("shellypro3em-", "-", "shelly");
addLog("MeterEmu: Shelly Pro 3EM mDNS registered");
} else {
mdnsRegister("ecotracker-", "-", "everhome");
addLog("MeterEmu: EcoTracker mDNS registered");
}
once_mdns = 1;
}
// One-time UDP bind (Shelly only)
if (emu_mode == 1 && once_udp == 0) {
udp_bind_port = 1010;
if (udp_port_sel == 1) udp_bind_port = 2220;
udp(0, udp_bind_port);
addLog("MeterEmu: Shelly UDP listening on port %d", udp_bind_port);
once_udp = 1;
}
}
// Simulator advances before the guards so getSml() has current values
#if defined(EMU_CHARTS) && defined(SIMULATE)
simTick();
#else
// ── From here on: work that needs a live meter + NTP ──
if (tasm_year < 2020) return;
if (getSml(2) == 0.0) return;
#endif
// WebUI button actions
if (do_init) { initVars(); do_init = 0; }
if (do_save) { saveVars(); do_save = 0; }
#ifdef EMU_CHARTS
if (do_init2) { resetCharts(); do_init2 = 0; }
#endif
// Throttle power updates
if (throttle < 1) throttle = 1;
if ((tasm_uptime % throttle) == 0) {
float raw = getSml(1);
float base = raw - (float)pwroffset;
if (ctrlopt > 0) {
if (raw > (float)pwroffset) {
cpwr = base / 4.0;
} else {
cpwr = base / 0.8;
}
} else {
cpwr = base;
}
// Heavy low-pass for EcoTracker's powerAvg
power3 = (0.9 * power3) + (0.1 * cpwr);
#ifdef EMU_CHARTS
// Light low-pass for chart sampling
power2 = (0.7 * power2) + (0.3 * cpwr);
#endif
}
#ifdef EMU_CHARTS
// MQTT publish (sndpwr) — every 10s
if (sndpwr > 0 && (tasm_uptime - mqtt_last) >= 10) {
sprintf(pubbuf, "Publish stat/PVEmu/PWR {\"Power\":%.0f}", cpwr);
tasmCmd(pubbuf, body);
mqtt_last = tasm_uptime;
}
// Chart sampling — 4h raster and 24h raster
if ((tasm_uptime % SAMPLE_4H_SEC) == 0) {
s4h[s4h_pos] = power2;
s4h_pos = s4h_pos + 1;
if (s4h_pos >= N4H) s4h_pos = 0;
if (s4h_cnt < N4H) s4h_cnt = s4h_cnt + 1;
}
if ((tasm_uptime % SAMPLE_24H_SEC) == 0) {
s24h[s24h_pos] = power2;
s24h_pos = s24h_pos + 1;
if (s24h_pos >= N24H) s24h_pos = 0;
if (s24h_cnt < N24H) s24h_cnt = s24h_cnt + 1;
}
#endif
// Midnight rollover: day / month / year baselines (+ chart rings)
int hr = tasm_hour;
if (hr == 0 && last_hr != 0) {
float ein = getSml(2);
float eout = getSml(3);
#ifdef EMU_CHARTS
// Ring push uses the OLD baselines — must run before they refresh
dcon[day_pos] = ein - dval;
dprod[day_pos] = eout - dval2;
day_pos = day_pos + 1;
if (day_pos >= NDAY) day_pos = 0;
if (day_cnt < NDAY) day_cnt = day_cnt + 1;
if (tasm_day == 1) {
mcon[mon_pos] = ein - mval;
mprod[mon_pos] = eout - mval2;
mon_pos = mon_pos + 1;
if (mon_pos >= NMON) mon_pos = 0;
if (mon_cnt < NMON) mon_cnt = mon_cnt + 1;
}
#endif
dval = ein;
dval2 = eout;
if (tasm_day == 1) {
mval = ein;
mval2 = eout;
}
if (tasm_day == 1 && tasm_month == 1) {
yval = ein;
yval2 = eout;
}
saveVars();
}
last_hr = hr;
}
// ─── Tasmota main sensor page: readout rows (+ chart status) ───────────────
void WebCall() {
if (emu_mode == 1) {
strcpy(body, "{s}Emulation{m}Shelly Pro 3EM{e}");
} else {
strcpy(body, "{s}Emulation{m}EcoTracker{e}");
}
webSend(body);
sprintf(body, "{s}Leistung (an PV-Akku){m}%.0f W{e}", cpwr);
webSend(body);
float ein = getSml(2);
float eout = getSml(3);
sprintf(body, "{s}Tagesverbrauch{m}%.2f kWh{e}", ein - dval); webSend(body);
sprintf(body, "{s}Monatsverbrauch{m}%.2f kWh{e}", ein - mval); webSend(body);
sprintf(body, "{s}Jahresverbrauch{m}%.2f kWh{e}", ein - yval); webSend(body);
sprintf(body, "{s}Tageseinspeisung{m}%.2f kWh{e}", eout - dval2); webSend(body);
sprintf(body, "{s}Monatseinspeisung{m}%.2f kWh{e}", eout - mval2); webSend(body);
sprintf(body, "{s}Jahreseinspeisung{m}%.2f kWh{e}", eout - yval2); webSend(body);
sprintf(body, "{s}Uptime{m}%d min{e}", tasm_uptime / 60); webSend(body);
#ifdef EMU_CHARTS
sprintf(body, "{s}Chart 4h / 24h{m}%d / %d pts{e}", s4h_cnt, s24h_cnt); webSend(body);
#endif
}
#ifdef EMU_CHARTS
// ─── Charts on the main page (emitted once per page load) ──────────────────
void WebPage() {
// ottelo's chart-centering compensation (margin-left:-30px wrapper) —
// wraps only the charts; the status strip + reload button keep their
// own text-align:center so they stay page-centered.
webSend("<div style='margin-left:-30px'>");
WebChart(0, "Leistung 4h", "W", 0xe74c3c, s4h_pos, s4h_cnt, s4h, 0, 1, 0, 0);
WebChart(0, "Leistung 24h", "W", 0x3498db, s24h_pos, s24h_cnt, s24h, 0, 5, 0, 0);
WebChart(1, "Tage (kWh)", "Verbrauch", 0x27ae60, day_pos, day_cnt, dcon, 2, 1440, 0, 0);
WebChart(1, "", "Einspeisung", 0xf39c12, day_pos, day_cnt, dprod, 2, 1440, 0, 0);
WebChart(1, "Monate (kWh)", "Verbrauch", 0x27ae60, mon_pos, mon_cnt, mcon, 1, 43200, 0, 0);
WebChart(1, "", "Einspeisung", 0xf39c12, mon_pos, mon_cnt, mprod, 1, 43200, 0, 0);
webSend("</div>");
sprintf(body, "<div style='text-align:center;font-size:12px;color:#666;margin:6px 0'>Punkte: 4h=%d · 24h=%d · Tage=%d · Monate=%d · Leistung: %.0f W · Auto-Reload: %ds</div>", s4h_cnt, s24h_cnt, day_cnt, mon_cnt, cpwr, chart_refresh);
webSend(body);
webSend("<div style='text-align:center;margin:8px 0'><button type='button' onclick='location.reload()' style='padding:6px 18px;border-radius:4px;font-size:13px'>🔄 Charts aktualisieren</button></div>");
if (chart_refresh > 0) {
sprintf(body, "<script>setTimeout(function(){location.reload();},%d000);</script>", chart_refresh);
webSend(body);
}
}
#endif
// ─── Settings page ─────────────────────────────────────────────────────────
void WebUI() {
int page = webPage();
if (page != 0) return;
webSend("<style>.mke-panel{max-width:320px;margin:12px auto;background:#f0f0f0;color:#000;padding:18px;border:2px solid #ccc;border-radius:6px;text-align:left}.mke-panel h3{margin:0 0 8px;font-size:15px}.mke-panel hr{border:0;border-top:1px solid #bbb;margin:14px 0}.mke-panel b{display:inline-block;margin-bottom:4px;font-size:13px}.mke-panel div{margin:6px 0}.mke-panel input[type=checkbox]{transform:scale(1.3);margin-left:6px}.mke-panel button{padding:6px 10px;border-radius:4px}</style>");
webSend("<div class='mke-panel'>");
#ifdef EMU_CHARTS
webSend("<h3>🔌 PV-Akku Emulator (Charts)</h3>");
#else
webSend("<h3>🔌 PV-Akku Emulator</h3>");
#endif
// Emulation mode
webSend("<b>⚙️ Modus</b>");
webPulldown(emu_mode, "Emulation (Neustart noetig)", "EcoTracker|Shelly Pro 3EM");
webSend("<hr>");
// Battery regulation
webSend("<b>🔋 Akku</b>");
webNumber(pwroffset, -200, 200, "Offset [W] Nulleinspeisung");
webCheckbox(ctrlopt, "Regleroptimierung");
// Shelly-only section
if (emu_mode == 1) {
webSend("<hr>");
webSend("<b>🌐 Shelly Pro 3EM</b>");
webNumber(throttle, 1, 60, "Update-Rate [s]");
webPulldown(udp_port_sel, "UDP-Port", "1010|2220 (B2500)");
webCheckbox(pwrforce, "Uebertragung erzwingen");
}
// Meter
webSend("<hr>");
webSend("<b>⚡ Stromzaehler</b>");
webCheckbox(sml_activ, "SML Zaehler aktiv");
webSend("📝 <a href='/ufse?file=/sml_meter.def'>Zaehler-Descriptor bearbeiten</a>");
webRepoPulldown(meter_sel, "Descriptor aus Repo",
"https://raw.githubusercontent.com/ottelo9/tasmota-sml-script/main/script-list-menu/meters/smartmeter.json",
"smartmeter",
"/sml_meter.def");
webPulldown(rx_pin, "RX Pin", "@getfreepins");
webPulldown(tx_pin, "TX Pin", "@getfreepins");
webCheckbox(smlfo, "Median-Filter");
#ifdef EMU_CHARTS
// Telemetry
webSend("<hr>");
webSend("<b>📡 Telemetrie</b>");
webCheckbox(sndpwr, "Leistung per MQTT publizieren");
// Chart auto-refresh
webSend("<hr>");
webSend("<b>📊 Diagramme</b>");
webNumber(chart_refresh, 0, 120, "Auto-Reload [s] (0 = aus)");
#endif
webSend("<hr>");
webSend("<b>💾 Daten</b>");
webButton(do_init, "Zaehler initialisieren|initialisiert");
webButton(do_save, "Daten speichern|gespeichert");
#ifdef EMU_CHARTS
webButton(do_init2, "Diagramme zuruecksetzen|zurueckgesetzt");
#endif
webSend("</div>");
}
// ─── MQTT telemetry (teleperiod push) ──────────────────────────────────────
void JsonCall() {
float ein = getSml(2);
float eout = getSml(3);
if (emu_mode == 1) {
sprintf(body, ",\"ShellyEmu\":{\"Power\":%.0f,\"EnergyIn\":%.3f,\"EnergyOut\":%.3f,\"DayIn\":%.3f,\"DayOut\":%.3f}", cpwr, ein, eout, ein - dval, eout - dval2);
} else {
sprintf(body, ",\"EcoTracker\":{\"Power\":%.0f,\"EnergyIn\":%.3f,\"EnergyOut\":%.3f,\"DayIn\":%.3f,\"DayOut\":%.3f}", cpwr, ein, eout, ein - dval, eout - dval2);
}
responseAppend(body);
}
// ─── main() — one-shot setup ───────────────────────────────────────────────
int main() {
once_mdns = 0;
once_udp = 0;
last_hr = -1;
udp_api_last = 0;
cpwr = 0.0;
power3 = 0.0;
do_init = 0;
do_save = 0;
#ifdef EMU_CHARTS
power2 = 0.0;
mqtt_last = 0;
do_init2 = 0;
if (chart_refresh == 0) chart_refresh = 15; // default auto-reload 15 s
#endif
if (throttle < 1) throttle = 1;
// First-run pin-picker defaults (persist 0 is a real GPIO; -1 = unset).
if (rx_pin == 0 && tx_pin == 0) { rx_pin = -1; tx_pin = -1; }
#if defined(EMU_CHARTS) && defined(SIMULATE)
if (sim_ein == 0.0) sim_ein = 100.0;
if (sim_eout == 0.0) sim_eout = 5.0;
sim_pwr = 0.0;
if (dval == 0.0) { dval = sim_ein - 3.0; dval2 = sim_eout - 1.0; }
if (mval == 0.0) { mval = sim_ein - 50.0; mval2 = sim_eout - 20.0; }
if (yval == 0.0) { yval = sim_ein - 500.0; yval2 = sim_eout - 200.0; }
if (s4h_cnt == 0 && s24h_cnt == 0 && day_cnt == 0 && mon_cnt == 0) {
simSeedHistory();
addLog("MeterEmu: seeded all chart rings with fake history");
}
addLog("MeterEmu: SIMULATE mode — fake meter data, accelerated sampling");
#endif
// Checkbox is the single source of truth at boot — make sure Rule1 is
// on before SML_Init runs if the user enabled SML in a prior session.
if (sml_activ != tasm_rule) {
tasm_rule = sml_activ;
}
tasmInfo(1, mac); // lowercase hex, no colons — used in Shelly UDP "src"
webOn(1, "/v1/json"); // EcoTracker
webOn(2, "/rpc/EM.GetStatus"); // Shelly
webOn(3, "/rpc/Shelly.GetStatus"); // Shelly
webPageLabel(0, "PV-Akku Emulator");
// First-run init: seed baselines from current meter reading
if (dval == 0.0 && getSml(2) > 0.0) {
initVars();
}
// Same Tasmota timezone setup as the Scripter versions
tasmCmd("Backlog2 Timezone 99;TimeStd 0,0,10,1,3,60;TimeDst 0,0,3,1,2,120", body);
// Unconditional mini-scripter load at boot — descriptor may already
// hold >F/>S sections; changed(meter_sel) is false at boot so the
// change-triggered load never fires here.
smlScripterLoad("/sml_meter.def");
addLog("MeterEmu: started mode=%d mac=%s rx=%d tx=%d meter=%d", emu_mode, mac, rx_pin, tx_pin, meter_sel);
return 0;
}