powerwall.tc¶
Powerwall — Tesla Powerwall API access
// Powerwall — Tesla Powerwall API access
// Replicates the Scripter powerwall_script.tas — and, like it, runs SINGLE-TASK.
//
// ARCHITECTURE (why there is no spawnTask worker):
// The Scripter version does the blocking Powerwall TLS (gpwl) right in its main
// `>S` loop, draws the LCD there, and renders the WebUI in `>W` — all on the ONE
// Tasmota loop task. Only a lightweight watchdog runs on a Scripter thread (ct).
// It has run for months without a crash. TinyC originally pushed pwlRequest into
// a spawnTask WORKER (to hide the ~1-2 s BearSSL block from loopTask), but that
// worker + the loopTask callbacks (EverySecond/WebCall) then SHARE one VM, and a
// callback stacking a frame on the parked worker corrupts it -> "Bounds error
// PC=0" (crash.log). The right fix is Scripter's: stay single-task. The blocking
// pwlRequest runs here in EverySecond; on a one-slot device the brief stalls are
// fine (proven by the Scripter version), and with no worker there is no shared-VM
// corruption, so the LCD + web page work again.
//
// Requires: TESLA_POWERWALL enabled in firmware build.
//
// ─── Publish mode — WebSend relay (default) vs direct UDP globals ───────────
// (undefined, default) WebSend relay — POST `Script>pwl=...` to a Scripter (.20)
// that re-broadcasts via UDP globals.
// #define PWL_DIRECT_GLOBALS — mirror readings into `global float` vars (auto-
// broadcast on assign), no relay. Compile -DPWL_DIRECT_GLOBALS.
// #define PWL_DIRECT_GLOBALS
#define PWL_STALE_SEC 300 // watchdog: Restart if no successful soe read this long
float l_pwl = 8.0;
float l_sip = 0.0;
float l_sop = 0.0;
float l_bip = 0.0;
float l_hip = 0.0;
float l_tcap = 0.0;
float l_rcap = 0.0;
float l_rper = 0.0;
float l_phs1 = 0.0;
float l_phs2 = 0.0;
float l_phs3 = 0.0;
// Solar string powers — only two strings exposed by the Powerwall; UI labels
// "Solar Phase 1/2" map to l_p1w / l_p3w.
float l_p1w = 0.0;
float l_p3w = 0.0;
// Per-CTS error strings — captured from the readings JSON each cycle. Empty = OK.
char l_cts1_err[48];
char l_cts2_err[48];
// ─── Publisher — pwl_publish() called every 30 s from EverySecond ───────────
#ifdef PWL_DIRECT_GLOBALS
global float pwl; global float sip; global float sop; global float bip;
global float hip; global float tcap; global float rcap; global float rper;
global float phs1; global float phs2; global float phs3;
void pwl_publish() {
pwl = l_pwl; sip = l_sip; sop = l_sop; bip = l_bip;
hip = l_hip; tcap = l_tcap; rcap = l_rcap; rper = l_rper;
phs1 = l_phs1; phs2 = l_phs2; phs3 = l_phs3;
}
#else
// WebSend relay: POST `Script>pwl=...` to a Scripter (.20) that re-broadcasts via
// UDP globals. %% → literal %; %3E='>', %3D='=', %3B=';' (Tasmota URL-decodes cmnd).
int WS_TARGET[] = "192.168.188.20";
char ws_url[256];
char ws_resp[256];
void pwl_publish() {
if (!tasm_wifi) { return; }
sprintf(ws_url,
"http://%s/cm?cmnd=Script%%3Epwl%%3D%.2f%%3Bsip%%3D%.2f%%3Bsop%%3D%.2f%%3Bbip%%3D%.2f%%3Bhip%%3D%.2f%%3Btcap%%3D%.0f%%3Brcap%%3D%.2f%%3Brper%%3D%.1f%%3Bphs1%%3D%.2f%%3Bphs2%%3D%.2f%%3Bphs3%%3D%.2f",
WS_TARGET,
l_pwl, l_sip, l_sop, l_bip, l_hip, l_tcap, l_rcap, l_rper, l_phs1, l_phs2, l_phs3);
httpGet(ws_url, ws_resp);
}
#endif
// State
char buf[128];
char scratch[256]; // sized for sprintf into webSend (clock header)
int lcd_initialized = 0; // labels drawn once at boot
int lcd_last_update = -10; // throttle LCD value refresh (uptime sec)
int pwl_cnt = 0; // Scripter's `cnt` — walks 0..18 then resets to -1
int pwl_ok_up = 0; // uptime of last SUCCESSFUL soe read (watchdog liveness)
int p1_zc = 0; // consecutive-zero count for solar leg 1 (glitch guard)
int p3_zc = 0; // consecutive-zero count for solar leg 2 (glitch guard)
// ═══════════════ CLOCK HEADER BLOCK (from examples/clock_header.tc) ═════
int web_clock_tick = 0;
void web_clock_header() {
web_clock_tick = web_clock_tick + 1;
char wd_names[] = "So|Mo|Di|Mi|Do|Fr|Sa";
char mo_names[] = "Jan|Feb|Mar|Apr|Mai|Jun|Jul|Aug|Sep|Okt|Nov|Dez";
char wd_label[4];
char mo_label[4];
strToken(wd_label, wd_names, '|', tasm_wday);
strToken(mo_label, mo_names, '|', tasm_month);
sprintf(scratch, "<tr><td colspan=2 style='text-align:center;background:#333;padding:8px;border-radius:8px'><span style='color:green;font-size:40px;font-weight:bold'>%02d:%02d:%02d</span><br>%s %d. %s %d <span style='font-size:0.7em;color:#888;'>● %d</span><br>",
tasm_hour, tasm_minute, tasm_second,
wd_label, tasm_day, mo_label, tasm_year, web_clock_tick);
webSend(scratch);
int sr = tasm_sunrise;
int ss = tasm_sunset;
int dl = ss - sr;
sprintf(scratch, "🌞 %02d:%02d <--- %02d:%02d ---> %02d:%02d 🌙</td></tr>",
sr / 60, sr % 60, dl / 60, dl % 60, ss / 60, ss % 60);
webSend(scratch);
}
// ── LCD layout (ported from the Scripter labels block). Title + 13 labels at
// x=15 y=60.. increment 20. Values rendered in lcd_update_values() at x=150.
// Safe from EverySecond (loopTask); a no-op if no display is configured.
void lcd_init_labels() {
dspClear();
dspText("[Ci5x0y20h70x170h70]");
dspText("[Ci16f1s1y18x90]Powerwall");
dspText("[f1s1Ci16]");
dspText("[x15y60]Battery %:");
dspText("[x15y80]Grid:");
dspText("[x15y100]Solar:");
dspText("[x15y120]Battery:");
dspText("[x15y140]Home:");
dspText("[x15y160]Tot Cap:");
dspText("[x15y180]Rem Cap:");
dspText("[x15y200]Rcap Lim:");
dspText("[x15y220]Solar 1:");
dspText("[x15y240]Solar 2:");
dspText("[x15y260]Phase 1:");
dspText("[x15y280]Phase 2:");
dspText("[x15y300]Phase 3:");
}
void lcd_update_values() {
sprintf(buf, "[Ci5x150y60p-10]%.2f %%", l_pwl); dspText(buf);
sprintf(buf, "[Ci5x150y80p-10]%.0f W", l_sip); dspText(buf);
sprintf(buf, "[Ci5x150y100p-10]%.0f W", l_sop); dspText(buf);
sprintf(buf, "[Ci5x150y120p-10]%.0f W", l_bip); dspText(buf);
sprintf(buf, "[Ci5x150y140p-10]%.0f W", l_hip); dspText(buf);
sprintf(buf, "[Ci5x150y160p-10]%.0f W", l_tcap); dspText(buf);
sprintf(buf, "[Ci5x150y180p-10]%.0f W", l_rcap); dspText(buf);
sprintf(buf, "[Ci5x150y200p-10]%.0f %%", l_rper); dspText(buf);
sprintf(buf, "[Ci5x150y220p-10]%.0f W", l_p1w); dspText(buf);
sprintf(buf, "[Ci5x150y240p-10]%.0f W", l_p3w); dspText(buf);
sprintf(buf, "[Ci5x150y260p-10]%.0f W", l_phs1); dspText(buf);
sprintf(buf, "[Ci5x150y280p-10]%.0f W", l_phs2); dspText(buf);
sprintf(buf, "[Ci5x150y300p-10]%.0f W", l_phs3); dspText(buf);
}
// ═══════════════ MAIN LOOP — single-task, mirrors the Scripter `>S` ═════
// Everything runs here on loopTask, once per second. The blocking pwlRequest
// (~1-2 s BearSSL) stalls loopTask briefly, exactly like the Scripter gpwl — fine
// for a one-slot device, and single-task means NO worker/callback VM sharing.
void EverySecond() {
// No TLS before the link is up (corrupts the heap / boot-loops an autoexec slot).
if (!tasm_wifi) { return; }
// LCD — safe HERE (dspText mutates the shared XdrvMailbox, which is fine from
// EverySecond since this IS loopTask; it would race loopTask from a worker task).
if (lcd_initialized == 0 && tasm_uptime > 3) { lcd_init_labels(); lcd_initialized = 1; }
if (lcd_initialized) {
dspText("[Ci3x50y40T]");
dspText("[x150y40tS]");
}
if (lcd_initialized && (tasm_uptime - lcd_last_update) >= 5) {
lcd_update_values();
lcd_last_update = tasm_uptime;
}
// Powerwall cnt state machine — ONE endpoint per second (Scripter `>S` switch cnt).
if (tasm_year > 2023) {
if (pwl_cnt == 0) {
int res = pwlRequest("/api/meters/aggregates");
if (res == 0) {
l_sip = pwlGet("site#instant_power");
l_bip = pwlGet("battery#instant_power");
l_hip = pwlGet("load#instant_power");
l_sop = pwlGet("solar#instant_power");
}
}
else if (pwl_cnt == 4) {
int res = pwlRequest("/api/system_status/soe");
if (res == 0) {
l_pwl = pwlGet("percentage");
pwl_ok_up = tasm_uptime; // liveness: a successful soe read
}
}
else if (pwl_cnt == 8) {
int res = pwlRequest("/api/system_status");
if (res == 0) {
l_tcap = pwlGet("nominal_full_pack_energy");
l_rcap = pwlGet("nominal_energy_remaining");
}
}
else if (pwl_cnt == 12) {
int res = pwlRequest("/api/operation");
if (res == 0) { l_rper = pwlGet("backup_reserve_percent"); }
}
else if (pwl_cnt == 14) {
int res = pwlRequest("/api/meters/readings");
if (res == 0) {
// Per-CT error gate — keep last good values when a CTS reports an error.
pwlStr("PW_CTS1#error", l_cts1_err);
if (l_cts1_err[0] == 0) {
// Solar-leg glitch guard: the CT clamps occasionally return a single
// momentary 0 for a leg (same reason the grid phases below are
// guarded). Solar IS legitimately 0 at night, so we can't blanket
// "keep last on 0"; instead suppress a SINGLE stray 0 (keep last
// good) and only accept a 0 that PERSISTS for 2 readings — a real
// shutoff / genuine no-sun still reaches 0 within ~40 s.
float new_p1w = pwlGet("p_W[1]");
float new_p3w = pwlGet("p_W[3]");
if (new_p1w != 0.0) { l_p1w = new_p1w; p1_zc = 0; }
else { p1_zc = p1_zc + 1; if (p1_zc >= 2) { l_p1w = 0.0; } }
if (new_p3w != 0.0) { l_p3w = new_p3w; p3_zc = 0; }
else { p3_zc = p3_zc + 1; if (p3_zc >= 2) { l_p3w = 0.0; } }
}
pwlStr("PW_CTS2#error", l_cts2_err);
if (l_cts2_err[0] == 0) {
// CT clamps occasionally return all three phases 0 W without the
// error flag — treat "all 3 = 0" as a glitch, keep last good.
float new_phs1 = pwlGet("p_W[5]");
float new_phs2 = pwlGet("p_W[6]");
float new_phs3 = pwlGet("p_W[7]");
if (new_phs1 != 0.0 || new_phs2 != 0.0 || new_phs3 != 0.0) {
l_phs1 = new_phs1; l_phs2 = new_phs2; l_phs3 = new_phs3;
}
}
}
}
else if (pwl_cnt == 18) {
pwl_cnt = -1; // next ++ → 0
}
pwl_cnt = pwl_cnt + 1;
}
// Publish (relay or direct globals) every 30 s.
if (tasm_uptime % 30 == 0) {
pwl_publish();
}
// Console heartbeat every 30 s.
if (tasm_uptime % 30 == 0) {
sprintf(buf, "PWL: Bat=%.1f%% Grid=%.0fW Sol=%.0fW Home=%.0fW\n",
l_pwl, l_sip, l_sop, l_hip);
printString(buf);
}
// Liveness watchdog — reboot if NO successful soe read for PWL_STALE_SEC (mirrors
// the Scripter >t1 `upd[pwl]`/fcnt>5 reboot). Liveness-based (successful READ),
// NOT value-change, so a battery SOC that legitimately sits constant for minutes
// does not trigger a false reboot.
if (pwl_ok_up == 0) { pwl_ok_up = tasm_uptime; } // prime at boot (grace)
if ((tasm_uptime - pwl_ok_up) >= PWL_STALE_SEC) {
addLog("PWL: no successful Powerwall read for 5 min — Restart 1");
char r[16];
tasmCmd("Restart 1", r);
pwl_ok_up = tasm_uptime; // avoid re-issuing every tick
}
}
void WebCall() {
// Clock header (big green clock + sunrise/sunset row + live tick)
web_clock_header();
sprintf(buf, "{s}Battery{m}%.1f %%{e}", l_pwl); webSend(buf);
sprintf(buf, "{s}Grid{m}%.0f W{e}", l_sip); webSend(buf);
sprintf(buf, "{s}Solar{m}%.0f W{e}", l_sop); webSend(buf);
sprintf(buf, "{s}Battery Power{m}%.0f W{e}", l_bip); webSend(buf);
sprintf(buf, "{s}Home{m}%.0f W{e}", l_hip); webSend(buf);
sprintf(buf, "{s}Total Capacity{m}%.1f kWh{e}", l_tcap / 1000.0); webSend(buf);
sprintf(buf, "{s}Remaining{m}%.1f kWh{e}", l_rcap / 1000.0); webSend(buf);
sprintf(buf, "{s}Reserve{m}%.0f %%{e}", l_rper); webSend(buf);
sprintf(buf, "{s}Solar Phase 1{m}%.0f W{e}", l_p1w); webSend(buf);
sprintf(buf, "{s}Solar Phase 2{m}%.0f W{e}", l_p3w); webSend(buf);
sprintf(buf, "{s}Phase 1{m}%.0f W{e}", l_phs1); webSend(buf);
sprintf(buf, "{s}Phase 2{m}%.0f W{e}", l_phs2); webSend(buf);
sprintf(buf, "{s}Phase 3{m}%.0f W{e}", l_phs3); webSend(buf);
// CTS error rows — green "OK" when empty, red <error> otherwise.
if (l_cts1_err[0] == 0) {
webSend("{s}CTS1 Error{m}<span style='color:green;'>OK</span>{e}");
} else {
sprintf(buf, "{s}CTS1 Error{m}<span style='color:red;'>%s</span>{e}", l_cts1_err);
webSend(buf);
}
if (l_cts2_err[0] == 0) {
webSend("{s}CTS2 Error{m}<span style='color:green;'>OK</span>{e}");
} else {
sprintf(buf, "{s}CTS2 Error{m}<span style='color:red;'>%s</span>{e}", l_cts2_err);
webSend(buf);
}
sprintf(buf, "{s}Heap{m}%d kb{e}", tasm_heap / 1024); webSend(buf);
}
void JsonCall() {
sprintf(buf, ",\"PWL\":{\"Battery\":%.1f", l_pwl); responseAppend(buf);
sprintf(buf, ",\"Grid\":%.0f", l_sip); responseAppend(buf);
sprintf(buf, ",\"Solar\":%.0f", l_sop); responseAppend(buf);
sprintf(buf, ",\"BattPwr\":%.0f", l_bip); responseAppend(buf);
sprintf(buf, ",\"Home\":%.0f}", l_hip); responseAppend(buf);
}
// Load Powerwall credentials from /powerwall.cfg (5 lines: ip / email / password /
// cts1 / cts2). Keeps secrets out of the source + the firmware binary.
int load_pwl_config() {
char raw[256];
int h = fileOpen("/powerwall.cfg", 0);
if (h < 0) return 0;
int n = fileRead(h, raw, 255);
fileClose(h);
if (n <= 0) return 0;
raw[n] = 0;
char ip[32]; ip[0] = 0;
char email[64]; email[0] = 0;
char pw[32]; pw[0] = 0;
char c1[24]; c1[0] = 0;
char c2[24]; c2[0] = 0;
int line = 0;
int j = 0;
for (int i = 0; i < n; i = i + 1) {
int c = raw[i] & 0xFF;
if (c == 13) continue; // skip \r
if (c == 10) { // \n → next field
if (line == 0) ip[j] = 0;
else if (line == 1) email[j] = 0;
else if (line == 2) pw[j] = 0;
else if (line == 3) c1[j] = 0;
else if (line == 4) c2[j] = 0;
line = line + 1;
j = 0;
if (line >= 5) break;
} else {
if (line == 0 && j < 31) { ip[j] = c; j = j + 1; }
else if (line == 1 && j < 63) { email[j] = c; j = j + 1; }
else if (line == 2 && j < 31) { pw[j] = c; j = j + 1; }
else if (line == 3 && j < 23) { c1[j] = c; j = j + 1; }
else if (line == 4 && j < 23) { c2[j] = c; j = j + 1; }
}
}
if (line == 0) ip[j] = 0;
else if (line == 1) email[j] = 0;
else if (line == 2) pw[j] = 0;
else if (line == 3) c1[j] = 0;
else if (line == 4) c2[j] = 0;
if (strlen(ip) < 7 || strlen(email) < 3 || strlen(pw) < 1) return 0;
char cmd[160];
sprintf(cmd, "@D%s,%s,%s", ip, email, pw);
pwlRequest(cmd);
if (strlen(c1) > 2 && strlen(c2) > 2) {
sprintf(cmd, "@C%s,%s", c1, c2);
pwlRequest(cmd);
}
return 1;
}
int main() {
if (!load_pwl_config()) {
addLog("PWL: cannot load /powerwall.cfg — upload a 5-line config (ip / email / password / cts1 / cts2) and restart slot");
} else {
addLog("PWL: credentials loaded from /powerwall.cfg");
}
// SINGLE-TASK — no spawnTask. The Powerwall polling + LCD + WebUI all run in
// EverySecond/WebCall on loopTask, exactly like the Scripter >S/>W. main() just
// loads credentials and returns; the slot then runs event-driven.
addLog("PWL: single-task (Scripter-style) — polling in EverySecond, no worker");
return 0;
}