Zum Inhalt

powerwall.tc

Powerwall — Tesla Powerwall API access

Source on GitHub

// 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;'>&#9679; %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, "&#127774; %02d:%02d <--- %02d:%02d ---> %02d:%02d &#127769;</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;
}