Skip to content

sma_sunnyboy.tc

sma_sunnyboy.tc — SMA Sunny Boy ueber Bluetooth Classic auslesen (SMAdata2+ / SPP).

Source on GitHub

// sma_sunnyboy.tc — SMA Sunny Boy ueber Bluetooth Classic auslesen (SMAdata2+ / SPP).
//
// Erprobt am SB 5000TL-20 (Seriennummer 2100072694). Liest die DC-Leistung und
// -Spannung GETRENNT JE MPPT-TRACKER sowie die AC-Wirkleistung — damit sieht man ohne
// Gang in den Keller, ob noch beide Strang-Zweige liefern.
//
// VORAUSSETZUNGEN
//   * Firmware mit USE_TINYC_SPP auf einem URSPRUENGLICHEN ESP32 (nur der hat
//     Bluetooth Classic; S3/C3/C6/P4 koennen ausschliesslich BLE)
//   * Der Wechselrichter bedient NUR EINE Gegenstelle. Sunny Beam, Handy-App oder
//     ein zweiter Leser sperren einander aus. Nach dem Trennen haelt das Geraet den
//     Platz noch etwa eine Minute.
//
// ⚠️ LAEUFT IN TaskLoop(), NICHT in einem spawnTask-Worker und nicht im Hauptlauf.
// Eine Messung dauert mit Verbinden und Anmelden leicht eine halbe Minute — im Hauptlauf
// wuerde Tasmota so lange stehen. Ein spawnTask-Worker waere aber FALSCH: der laeuft auf
// einer eigenen VM mit eigenem Heap und kennt die Feld-Tabelle aus dem .tcb-Kopf nicht,
// dort hat JEDES Feld Groesse 0 und der erste Zugriff endet in Laufzeitfehler 9
// (TCC: BOUNDS heap[0] idx=0 size=0). Genau daran ist dieses Skript am 2026-08-02
// dreimal gestorben. TaskLoop() laeuft auf der Haupt-VM: alle Felder da, delay() erlaubt.
//
// DIE VIER FALLEN DIESES GERAETS (alle gemessen, nicht vermutet — sie haben uns
// beim Entwickeln am Mac je einen halben Nachmittag gekostet):
//   1. VOR der eigentlichen Antwort schickt der Wechselrichter zwei unaufgeforderte
//      Statusmeldungen (Subtyp 0xFFFD, 20 Byte "aaaabbbb..."). Die richtige Antwort
//      kam als DRITTES Paket.
//   2. Deshalb wird auf die passende MARKE gewartet, statt einfach das naechste
//      Paket zu nehmen: gesendet als tag|0x8000, zurueck als tag&0x7FFF.
//   3. HINTER der Antwort haengt oft ein Rest, der sich nicht als Rahmen lesen
//      laesst. Der darf die Suche nicht abbrechen, sondern wird uebersprungen.
//   4. Datensaetze koennen ABGESCHNITTEN ankommen — die Spannungsantwort kam mit 46
//      statt 56 Byte, der zweite Tracker endete nach 18 Byte. Sein WERT stand aber
//      vollstaendig da. Wer volle 28 Byte verlangt, verwirft still die halbe Messung.
//      Deshalb genuegen hier 12 Byte je Satz.

// ---------------------------------------------------------------------------------
// Einstellungen
// ---------------------------------------------------------------------------------
#define SMA_ADR   "00:80:25:09:AC:E7"   // Bluetooth-Adresse des Wechselrichters
#define SMA_PASS  "0000"                // Benutzerkennwort, SMA-Vorgabe
#define TAKT_S    900                   // Abstand der Messungen; nur EIN Platz!
#define UDP_TAKT_S 60                   // Abstand der UDP-Sendungen
#define UDP_NAME  "sma"                 // Name im Scripter-Protokoll
// Aeusserer Empfaenger der Abfragerahmen: 0 = Rundruf (FF..), 1 = die Adresse des
// Geraets. ⚠️ RUNDRUF ist richtig — die erprobte Referenzumsetzung sendet AUCH nach der
// Anmeldung an ff:ff:ff:ff:ff:ff. Gezielte Adressierung wurde geprueft und half nicht.
#define ABFRAGE_GEZIELT 0

#define SPUR      0                     // 1 = jeden Empfangsschritt protokollieren.
                                        // Zum Nachstellen einschalten: zeigt jeden
                                        // Rahmen, die Rohbytes der Anmeldeantwort, den
                                        // gesendeten Abfragerahmen und den Speicherstand
                                        // an jeder Station. Genau damit wurden die drei
                                        // Fallen oben gefunden.

// ⚠️ Wartezeiten je EINZELNEM Rahmen, nicht je Abfrage.
//
// ⭐ HANDSCHLAG UND MESSWERTE HABEN VOELLIG VERSCHIEDENE ANTWORTZEITEN. Begruessung und
// Anmeldung kommen in etwa 200 ms zurueck. Die MESSWERTE brauchen rund ZEHN SEKUNDEN —
// das Geraet sammelt sie erst zusammen. Wer daraus schliesst, 3 s seien grosszuegig,
// schneidet genau die Antworten ab, auf die es ankommt: das Skript sieht dann nur
// Zeitablaeufe und der Rahmen sieht schuldig aus, obwohl er stimmt. (Genau das ist mir
// am 2026-08-03 passiert; die Referenzumsetzung ha-sma-sunnyboy-bluetooth arbeitet mit
// 8 s Sockelfrist.)
//
// Die urspruenglichen 30 s x 8 Versuche x 3 Abfragen waren allerdings ebenfalls falsch:
// ein fehlschlagender Zyklus dauerte damit bis zu ZWOELF MINUTEN ohne Lebenszeichen.
#define FRIST_RAHMEN  15000             // auf einen Antwortrahmen einer Abfrage
#define FRIST_ANMELD  8000              // auf die Antwort der Anmeldung
#define VERSUCHE      4                 // Rahmen abwarten, bis die Marke passt

// Messwerte, ueberleben einen Neustart
persist int  dc_a;      // W, Tracker A
persist int  dc_b;      // W, Tracker B
persist int  u_a;       // Hundertstel Volt
persist int  u_b;
persist int  ac_w;      // W
persist int  letzte_zeit;

int lauf_zaehler;
int fehler_zaehler;
int sofort_messen;   // vom Kommando gesetzt, vom Worker abgeraeumt

// Puffer
// ⚠️ JEDES BYTE ZAEHLT. Gemessen auf .185: vor sppInit 70 KB frei, nach dem Hochfahren
// des Bluedroid-Stapels 21,7 KB, mit OFFENEM Kanal nur noch 2,3 KB — der Kanal allein
// kostet 19 KB. Und jeder Funktionsaufruf der VM fordert 1 KB Rahmenspeicher an, dessen
// Fehlschlag als "Stack overflow" gemeldet wird. Die Felder unten sind deshalb auf das
// GEMESSENE Mass gestutzt: der groesste je gesehene Rahmen dieses Geraets hatte 109 Byte
// (Begruessung 31, Aufbau 20/34, Anmeldung 83, Messwerte 28..109).
char rx[256];           // empfangener Rahmen — 2x der groesste beobachtete
char tx[192];           // zu sendender Rahmen
char ppp[192];          // PPP-Zwischenstufe
char inner[112];        // Inhalt vor der PPP-Verpackung
int  marke;

// ⚠️ Spurausgabe NUR ueber diese Hilfsfunktion. Ein addLog mit vier Werten direkt in
// sma_empfange() hat den Operandenstapel der VM gesprengt (256 Plaetze, Aufrufkette
// TaskLoop -> sma_messen -> sma_begruessung -> sma_empfange): Absturz mit
// "Stack overflow" nach 576 Anweisungen, noch bevor die erste Spurzeile erschien.
// Ein Aufruf mit EINEM Wert bleibt flach.
char spur[64];

// ⚠️ Speicherstand melden. JEDER Funktionsaufruf der VM fordert 1 KB Rahmenspeicher per
// calloc an; schlaegt das fehl, meldet die VM "Stack overflow" (tc_frame_alloc gibt
// TC_ERR_STACK_OVERFLOW zurueck, an fuenf Stellen im VM-Code). Der Fehlertext zeigt also
// auf den Stapel, gemeint ist der HEAP. Hier zaehlt nicht die freie Summe, sondern der
// GROESSTE ZUSAMMENHAENGENDE BLOCK — 30 KB frei in 700-Byte-Stuecken nuetzen nichts.
void hp(char wo[]) {
    sprintf(spur, "SPEICHER %s: frei %d, groesster Block %d", wo, tasm_heap, tasm_maxblock);
    addLog(spur);
}

// Rohbytes aus rx zeigen, vier je Zeile. Flach halten: von sma_anmelden aus ist das
// Ebene 4, tiefer darf es mit offenem Bluetooth-Kanal nicht werden.
void roh_zeigen(int n) {
    int i = 0;
    if (n > 64) { n = 64; }
    while (i + 4 <= n) {
        sprintf(spur, "ROH %d: %02X %02X %02X %02X", i,
                rx[i] & 0xFF, rx[i+1] & 0xFF, rx[i+2] & 0xFF, rx[i+3] & 0xFF);
        addLog(spur);
        i = i + 4;
    }
}

void sp1(char text[], int wert) {
    sprintf(spur, "EMPF: %s %d", text, wert);
    addLog(spur);
}

// ---------------------------------------------------------------------------------
// PPP-Pruefsumme: CRC-16/X-25, gespiegelt, Polynom 0x8408, am Ende invertiert
// ---------------------------------------------------------------------------------
int sma_crc(char d[], int n) {
    int c = 0xFFFF;
    int i = 0;
    while (i < n) {
        c = c ^ (d[i] & 0xFF);
        int b = 0;
        while (b < 8) {
            if (c & 1) { c = ((c >> 1) ^ 0x8408) & 0xFFFF; }
            else       { c = (c >> 1) & 0xFFFF; }
            b = b + 1;
        }
        i = i + 1;
    }
    return (~c) & 0xFFFF;
}

// ---------------------------------------------------------------------------------
// Aeusserer Rahmen senden.
// Aufbau: 7E | Laenge(2) | Pruefsumme | Absender(6) | Empfaenger(6) | Typ(2) | Nutzlast
// ⚠️ Die Adressen stehen RUECKWAERTS auf der Leitung.
// ---------------------------------------------------------------------------------
void sma_sende_aussen(int an_geraet, int typ, char nutz[], int nlen) {
    int plen = nlen + 18;
    tx[0] = 0x7E;
    tx[1] = plen & 0xFF;
    tx[2] = 0;
    tx[3] = (0x7E ^ tx[1] ^ tx[2]) & 0xFF;
    // Absender: immer 00:00:00:00:00:00
    int i = 0;
    while (i < 6) { tx[4 + i] = 0; i = i + 1; }
    // Empfaenger: entweder Rundruf (FF..) oder die Adresse des Geraets
    i = 0;
    while (i < 6) {
        if (an_geraet) { tx[10 + i] = adr_b[5 - i]; }
        else           { tx[10 + i] = 0xFF; }
        i = i + 1;
    }
    tx[16] = typ & 0xFF;
    tx[17] = (typ >> 8) & 0xFF;
    i = 0;
    while (i < nlen) { tx[18 + i] = nutz[i]; i = i + 1; }
    sppWrite(tx, plen);
}

// Ein Byte der Geraeteadresse (0 = erstes Byte 0x00, 5 = letztes 0xE7).
// Aus dem Text SMA_ADR gelesen, damit nur eine Stelle geaendert werden muss.
// ⚠️ Mit Initialisierung deklarieren, nicht spaeter zuweisen: die Zuweisung
// einer Zeichenkette an ein spaeter deklariertes char[] endete in einer
// Bereichsueberschreitung (Laufzeitfehler 9), obwohl 17 Zeichen in 20 passen.
char adr_text[24];
// ⭐ Die sechs Adressbytes EINMAL beim Start ausrechnen, nicht bei jedem Rahmen neu.
// Nicht aus Geschwindigkeitsgruenden, sondern wegen des Speichers: sma_sende_aussen()
// rief sma_adr_byte(), das rief hexziffer() — zwei zusaetzliche Ebenen in der tiefsten
// Aufrufkette. Jede Ebene kostet 1 KB Rahmenspeicher am STUECK, und mit offenem
// Bluetooth-Kanal ist der groesste freie Block nur noch rund 6 KB (gemessen: 20 KB frei,
// groesster Block 6044). Sieben Ebenen passten nicht mehr hinein, fuenf passen.
int adr_b[6];
int sma_adr_byte(int idx) {
    int p = idx * 3;
    int hi = hexziffer(adr_text[p]);
    int lo = hexziffer(adr_text[p + 1]);
    return ((hi << 4) | lo) & 0xFF;
}

int hexziffer(int c) {
    if (c >= 48 && c <= 57)  { return c - 48; }        // 0-9
    if (c >= 65 && c <= 70)  { return c - 65 + 10; }   // A-F
    if (c >= 97 && c <= 102) { return c - 97 + 10; }   // a-f
    return 0;
}

// ---------------------------------------------------------------------------------
// PPP verpacken: FF 03 60 65 | Inhalt | CRC, dann 7E-gerahmt und maskiert
// ---------------------------------------------------------------------------------
int sma_mache_ppp(char roh[], int n) {
    // Kopf voranstellen
    char mit[144];
    mit[0] = 0xFF; mit[1] = 0x03; mit[2] = 0x60; mit[3] = 0x65;
    int i = 0;
    while (i < n) { mit[4 + i] = roh[i]; i = i + 1; }
    int m = n + 4;
    // CRC hier eingebettet statt als Aufruf — spart eine Ebene Rahmenspeicher (siehe
    // Hinweis bei adr_b). Identisch zu sma_crc(), das fuer andere Zwecke stehen bleibt.
    int crc = 0xFFFF;
    int ci = 0;
    while (ci < m) {
        crc = crc ^ (mit[ci] & 0xFF);
        int cb = 0;
        while (cb < 8) {
            if (crc & 1) { crc = ((crc >> 1) ^ 0x8408) & 0xFFFF; }
            else         { crc = (crc >> 1) & 0xFFFF; }
            cb = cb + 1;
        }
        ci = ci + 1;
    }
    crc = (~crc) & 0xFFFF;
    mit[m] = crc & 0xFF; m = m + 1;
    mit[m] = (crc >> 8) & 0xFF; m = m + 1;

    // Maskieren: 7E, 7D, 11, 13 werden zu 7D + (Wert ^ 0x20)
    int o = 0;
    ppp[o] = 0x7E; o = o + 1;
    i = 0;
    while (i < m) {
        int c = mit[i] & 0xFF;
        if (c == 0x7E || c == 0x7D || c == 0x11 || c == 0x13) {
            ppp[o] = 0x7D; o = o + 1;
            ppp[o] = c ^ 0x20; o = o + 1;
        } else {
            ppp[o] = c; o = o + 1;
        }
        i = i + 1;
    }
    ppp[o] = 0x7E; o = o + 1;
    return o;
}

// Maskierung aufloesen. Gibt die Laenge zurueck, schreibt nach `aus`.
int sma_loese_ppp(char roh[], int start, int rlen, char aus[]) {
    if (rlen < 2) { return 0; }
    if ((roh[start] & 0xFF) != 0x7E) { return 0; }
    int o = 0;
    int i = start + 1;
    int ende = start + rlen - 1;
    while (i < ende) {
        if ((roh[i] & 0xFF) == 0x7D) {
            i = i + 1;
            aus[o] = (roh[i] ^ 0x20) & 0xFF;
        } else {
            aus[o] = roh[i] & 0xFF;
        }
        o = o + 1;
        i = i + 1;
    }
    return o;
}

// ---------------------------------------------------------------------------------
// Anfrage im 6560-Format zusammenbauen und senden
// ---------------------------------------------------------------------------------
void sma_sende_6560(int tag, int typ, int subtyp, int arg1, int arg2,
                    char extra[], int elen, int b1, int b2, int c1, int c2) {
    int n = 0;
    inner[n] = ((elen + 36) / 4) & 0xFF; n = n + 1;
    inner[n] = 0xA0; n = n + 1;
    int i = 0;
    while (i < 6) { inner[n] = 0xFF; n = n + 1; i = i + 1; }
    inner[n] = b1 & 0xFF; n = n + 1;
    inner[n] = b2 & 0xFF; n = n + 1;
    i = 0;
    while (i < 6) { inner[n] = 0; n = n + 1; i = i + 1; }
    inner[n] = c1 & 0xFF; n = n + 1;
    inner[n] = c2 & 0xFF; n = n + 1;
    i = 0;
    while (i < 4) { inner[n] = 0; n = n + 1; i = i + 1; }
    // Marke mit gesetztem obersten Bit — die Antwort liefert sie ohne dieses Bit
    int t = (tag | 0x8000) & 0xFFFF;
    inner[n] = t & 0xFF; n = n + 1;
    inner[n] = (t >> 8) & 0xFF; n = n + 1;
    inner[n] = typ & 0xFF; n = n + 1;
    inner[n] = (typ >> 8) & 0xFF; n = n + 1;
    inner[n] = subtyp & 0xFF; n = n + 1;
    inner[n] = (subtyp >> 8) & 0xFF; n = n + 1;
    inner[n] = arg1 & 0xFF; n = n + 1;
    inner[n] = (arg1 >> 8) & 0xFF; n = n + 1;
    inner[n] = (arg1 >> 16) & 0xFF; n = n + 1;
    inner[n] = (arg1 >> 24) & 0xFF; n = n + 1;
    inner[n] = arg2 & 0xFF; n = n + 1;
    inner[n] = (arg2 >> 8) & 0xFF; n = n + 1;
    inner[n] = (arg2 >> 16) & 0xFF; n = n + 1;
    inner[n] = (arg2 >> 24) & 0xFF; n = n + 1;
    i = 0;
    while (i < elen) { inner[n] = extra[i]; n = n + 1; i = i + 1; }

    int plen = sma_mache_ppp(inner, n);
    sma_sende_aussen(ABFRAGE_GEZIELT, 0x01, ppp, plen);
#if SPUR
    // Den GESENDETEN Rahmen zeigen. Solange die Abfragen unbeantwortet bleiben, ist die
    // erste Frage, ob ueberhaupt das Richtige auf der Leitung liegt — und nur der
    // Vergleich Byte fuer Byte mit einer erprobten Umsetzung beantwortet sie.
    // Flach halten: hier sind wir schon vier Ebenen tief.
    int si = 0;
    int sn = plen + 18;
    if (sn > 48) { sn = 48; }
    while (si + 4 <= sn) {
        sprintf(spur, "SEND %d: %02X %02X %02X %02X", si,
                tx[si] & 0xFF, tx[si+1] & 0xFF, tx[si+2] & 0xFF, tx[si+3] & 0xFF);
        addLog(spur);
        si = si + 4;
    }
#endif
}

// ---------------------------------------------------------------------------------
// Einen vollstaendigen aeusseren Rahmen empfangen. Laenge steht im Kopf.
// Gibt die Laenge zurueck, 0 bei Zeitueberschreitung.
// ⚠️ NICHT blockierend lesen — sppRead liefert nur, was da ist. Das Skript wartet
// selbst, damit die VM ansprechbar bleibt.
// ---------------------------------------------------------------------------------
int sma_empfange(int frist_ms) {
    int ende = millis() + frist_ms;
    int haben = 0;
    // Erst den 4 Byte langen Kopf
    // ⚠️ In einen ZWISCHENPUFFER lesen. sppRead schreibt immer ab Index 0, und ein
    // Umkopieren rx[haben+k] = rx[k] innerhalb DESSELBEN Feldes ueberschreibt sich
    // selbst, sobald haben < n ist (haben=1, n=3: rx[2] liest das schon geaenderte
    // rx[1]). Faellt nur bei zerstueckelten Paketen auf — also selten und dann falsch.
    char kopf[4];
    while (haben < 4) {
        int n = sppRead(kopf, 4 - haben);
        if (n > 0) {
            int k = 0;
            while (k < n) { rx[haben + k] = kopf[k]; k = k + 1; }
            haben = haben + n;
        } else {
            if (millis() > ende) {
#if SPUR
                sp1("KEINE ANTWORT, Zeitablauf im Kopf nach ms", frist_ms);
#endif
                return 0;
            }
            delay(20);
        }
    }
#if SPUR
    sp1("erstes Byte", rx[0] & 0xFF);
#endif
    if ((rx[0] & 0xFF) != 0x7E) {
#if SPUR
        sp1("kein 7E, verworfen", 0);
#endif
        return 0;
    }
    int plen = (rx[1] & 0xFF) | ((rx[2] & 0xFF) << 8);
    if (plen < 18 || plen > 256) {
#if SPUR
        sp1("Laenge unbrauchbar", plen);
#endif
        return 0;
    }
    // Dann der Rest
    while (haben < plen) {
        int rest = plen - haben;
        char tmp[128];
        int n = sppRead(tmp, rest);
        if (n > 0) {
            int k = 0;
            while (k < n) { rx[haben + k] = tmp[k]; k = k + 1; }
            haben = haben + n;
        } else {
            if (millis() > ende) {
#if SPUR
                sp1("Zeitablauf im Rumpf, Byte", haben);
#endif
                return 0;
            }
            delay(20);
        }
    }
#if SPUR
    sp1("Rahmen vollstaendig, Byte", plen);
#endif
    return plen;
}

// ---------------------------------------------------------------------------------
// Anmeldung
// ---------------------------------------------------------------------------------
int sma_begruessung() {
    int n = sma_empfange(20000);
    if (n < 18) { return 0; }
    // Die Nutzlast der Begruessung wird unveraendert zurueckgespiegelt.
    char echo[80];
    int i = 0;
    int m = n - 18;
    if (m > 80) { m = 80; }
    while (i < m) { echo[i] = rx[18 + i]; i = i + 1; }
    sma_sende_aussen(1, 0x02, echo, m);
    // Drei weitere Rahmen gehoeren zum Aufbau und werden nur weggelesen.
    i = 0;
    while (i < 3) { sma_empfange(5000); i = i + 1; }
    return 1;
}

int sma_anmelden() {
    char extra[24];
    int i = 0;
    while (i < 24) { extra[i] = 0; i = i + 1; }
    extra[0] = 0xAA; extra[1] = 0xAA; extra[2] = 0xBB; extra[3] = 0xBB;
    // Kennwort auf 12 Byte mit Null gefuellt, jedes Byte (c + 0x88) % 0xFF
    char pw[13];
    strcpy(pw, SMA_PASS);
    i = 0;
    while (i < 12) {
        int c = 0;
        if (i < strlen(pw)) { c = pw[i] & 0xFF; }
        extra[8 + i] = ((c + 0x88) % 0xFF) & 0xFF;
        i = i + 1;
    }
    marke = marke + 1;
    sma_sende_6560(marke, 0x040C, 0xFFFD, 7, 900, extra, 20, 0, 1, 0, 1);

    int n = sma_empfange(FRIST_ANMELD);
    if (n < 18) { return 0; }
#if SPUR
    roh_zeigen(n);
#endif
    // Erfolg pruefen — siehe den Hinweis bei sma_fehlerwort().
    int rl = sma_loese_ppp(rx, 18, n - 18, roh);
    if (rl >= 42) {
        int fehler = sma_fehlerwort();
        if (fehler != 0) {
            sprintf(spur, "SMA: Anmeldung abgelehnt, Fehlerwort 0x%04X — Kennwort?", fehler);
            addLog(spur);
            return 0;
        }
    }
    return 1;
}

// ---------------------------------------------------------------------------------
// Eine Abfrage stellen und die Antwort mit PASSENDER MARKE abwarten.
// Schreibt die Nutzdaten nach `daten`, gibt deren Laenge zurueck (0 = nichts).
// ---------------------------------------------------------------------------------
char daten[160];
// ⚠️ EIN gemeinsamer Entmaskierungspuffer fuer Anmeldung UND Abfrage. Zwei ortsfeste
// Felder gleicher Groesse haetten 900 Byte Heap doppelt belegt — bei rund 20 KB frei mit
// offenem Bluetooth-Kanal ist das nicht zu verschenken. Sie werden nie gleichzeitig
// gebraucht (beide leben nur innerhalb eines Rahmens).
char roh[224];

// ⭐ DAS FEHLERWORT — und die Falle, die uns den Nachmittag gekostet hat.
//
// Im 6560-Kopf steht an Position 18..19 ein FEHLERWORT (0 = alles gut). Das unterste Bit
// des KOMMANDOWORTS dagegen ist KEIN Fehlerbit, sondern das ANTWORT-Bit: eine Erwiderung
// auf 0x040C kommt als 0x040D zurueck, eine auf 0x0200 als 0x0201. Wer das als Fehler
// deutet, verwirft JEDE gueltige Antwort — und sieht nur stumme Abfragen, obwohl das
// Geraet ordentlich geantwortet hat. Genau das stand hier urspruenglich.
// Belegt durch die Referenzumsetzung ha-sma-sunnyboy-bluetooth (Feld `response` =
// `bool(typ_raw & 1)`, Feld `error` = `u16(data[18:20])`) und durch unseren eigenen
// mitgeschnittenen Anmelderahmen: Fehlerwort 0x0000, Kommando 0x040D.
int sma_fehlerwort() {
    return ((roh[4 + 18] & 0xFF) | ((roh[4 + 19] & 0xFF) << 8)) & 0xFFFF;
}
int sma_abfrage(int subtyp, int erst, int letzt) {
    marke = marke + 1;
    int meine = marke;
    char leer[1];
    sma_sende_6560(meine, 0x0200, subtyp, erst, letzt, leer, 0, 0, 0, 0, 0);

    int versuch = 0;
    while (versuch < VERSUCHE) {
        int n = sma_empfange(FRIST_RAHMEN);
        if (n < 18) { return 0; }
        int rl = sma_loese_ppp(rx, 18, n - 18, roh);
        // Falle 3: unlesbarer Rest darf die Suche nicht abbrechen
        if (rl >= 42) {
            // roh[0..3] = FF 03 60 65, danach der Inhalt; CRC am Ende abziehen
            int tag = ((roh[4 + 22] & 0xFF) | ((roh[4 + 23] & 0xFF) << 8)) & 0x7FFF;
            int typ = (roh[4 + 24] & 0xFF) | ((roh[4 + 25] & 0xFF) << 8);
            if (tag == meine) {
                int fehler = sma_fehlerwort();
                if (fehler != 0) {                // 0 = alles gut, siehe sma_fehlerwort()
                    sprintf(spur, "SMA: Abfrage abgelehnt, Fehlerwort 0x%04X", fehler);
                    addLog(spur);
                    return 0;
                }
                int dlen = rl - 4 - 2 - 36;
                if (dlen < 0) { dlen = 0; }
                if (dlen > 160) { dlen = 160; }
                int i = 0;
                while (i < dlen) { daten[i] = roh[4 + 36 + i]; i = i + 1; }
                return dlen;
            }
        }
        versuch = versuch + 1;
    }
    return 0;
}

// ---------------------------------------------------------------------------------
// Datensaetze auswerten.
// Aufbau: 4 B Kennung (Bits 8..23 = LRI, Bits 0..7 = Klasse), 4 B Zeit,
// dann viermal derselbe Wert. Abstand 28 Byte.
// ⚠️ Falle 4: ein abgeschnittener letzter Satz MUSS gelten — 12 Byte genuegen.
// ---------------------------------------------------------------------------------
void sma_auswerten(int kennung, int dlen) {
    int pos = 0;
    while (pos + 12 <= dlen) {
        int code = (daten[pos] & 0xFF) | ((daten[pos+1] & 0xFF) << 8)
                 | ((daten[pos+2] & 0xFF) << 16) | ((daten[pos+3] & 0xFF) << 24);
        int lri = (code >> 8) & 0xFFFF;
        int klasse = code & 0xFF;
        int wert = (daten[pos+8] & 0xFF) | ((daten[pos+9] & 0xFF) << 8)
                 | ((daten[pos+10] & 0xFF) << 16) | ((daten[pos+11] & 0xFF) << 24);
#if SPUR
        sprintf(spur, "SATZ pos=%d lri=%04X klasse=%d wert=%d", pos, lri, klasse, wert);
        addLog(spur);
#endif
        if (lri == kennung) {
            if (kennung == 0x251E) {
                if (klasse == 1) { dc_a = wert; }
                if (klasse == 2) { dc_b = wert; }
            }
            if (kennung == 0x451F) {
                if (klasse == 1) { u_a = wert; }
                if (klasse == 2) { u_b = wert; }
            }
            if (kennung == 0x263F) {
                if (klasse == 1) { ac_w = wert; }
            }
        }
        pos = pos + 28;
    }
}

// ---------------------------------------------------------------------------------
// Eine vollstaendige Messung
// ---------------------------------------------------------------------------------
int sma_messen() {
#if SPUR
    hp("vor sppInit");
#endif
    // ⛔ NICHT HOCHFAHREN, WENN DER SPEICHER KNAPP IST — das ist kein Vorsichtsluxus,
    // sondern verhindert einen HARTEN ABSTURZ des ganzen Geraets.
    //
    // Mit CONFIG_BT_BLE_DYNAMIC_ENV_MEMORY=y legt Bluedroids bta_sys_init() seine
    // Verwaltungsdaten per malloc an und macht anschliessend ein memset DARAUF, ohne den
    // Rueckgabewert zu pruefen. Reicht der Speicher nicht, schreibt es auf NULL:
    //     Exception 29 StoreProhibited, EXCVADDR 00000000
    //     memset -> bta_sys_init (bta_sys_main.c:172) -> btu_task_start_up
    // Kein Fehlercode, kein Log, Neustart. Gemessen am 2026-08-03 auf .185.
    //
    // Der Stapel braucht rund 48 KB, davon ein grosses zusammenhaengendes Stueck. Lieber
    // eine Messung auslassen als das Geraet neu starten — beim naechsten Takt ist der
    // Speicher meist wieder da (der Abbau nach jeder Messung gibt ihn frei).
    if (tasm_heap < 60000 || tasm_maxblock < 30000) {
        sprintf(spur, "SMA: Messung ausgelassen — nur %d Byte frei, groesster Block %d",
                tasm_heap, tasm_maxblock);
        addLog(spur);
        return 0;
    }
    if (!sppInit()) { addLog("SMA: Bluetooth nicht verfuegbar"); return 0; }
#if SPUR
    hp("nach sppInit");
#endif

    // ⚠️ IMMER zuerst trennen. Ein Kanal, der von einem frueheren Lauf (oder einem
    // anderen Skript) noch offen steht, laesst sppConnect() sofort mit Erfolg
    // zurueckkehren — ohne dass ein Verbindungsaufbau stattgefunden haette. Die
    // Begruessung faellt dann mitten hinein und das Skript wartet ins Leere.
    // Der Wechselrichter haelt seinen einzigen Platz nach dem Trennen noch etwa eine
    // Minute; deshalb hier eine kurze Pause statt sofort neu zu rufen.
    if (sppState() == 3) { addLog("SMA: alter Kanal noch offen — trenne"); }
    sppClose();
    delay(2000);

    addLog("SMA: verbinde ...");
    sppConnect(adr_text, 1);             // Kanal 1 = Dienst "cluster network"

    // Auf den Kanal warten (Zustand 3 = offen, 4 = gescheitert).
    // ⚠️ 30 s, weil die Firmware den Page-Timeout auf 20 s stellt (TC_SPP_PAGE_TIMEOUT).
    // Wer hier kuerzer wartet, gibt vor dem Controller auf und sieht den echten Grund nie.
    int ende = millis() + 30000;
    int zst = sppState();
    while (zst != 3) {
        if (zst == 4) {
            addLog("SMA: Verbindung gescheitert — Grund steht im SPP-Protokoll");
            sppClose();
            return 0;
        }
        if (millis() > ende) {
            addLog("SMA: keine Verbindung — Platz belegt?");
            sppClose();
            return 0;
        }
        delay(200);
        zst = sppState();
    }

#if SPUR
    hp("Kanal offen");
#endif
    addLog("SMA: Kanal offen, warte auf Begruessung");
    int ok = 0;
    if (sma_begruessung()) {
        addLog("SMA: Begruessung beantwortet, melde an");
    #if SPUR
    hp("vor Anmeldung");
#endif
        if (sma_anmelden()) {
            addLog("SMA: angemeldet, frage Messwerte ab");
        #if SPUR
    hp("nach Anmeldung");
#endif
            // ⚠️ JEDE Abfrage einzeln melden. Frueher galt der Zyklus als gelungen,
            // sobald EINE der drei Antworten kam — die uebrigen Werte behielten dann
            // still ihren alten Inhalt und wanderten als vermeintlich frische Messung
            // ins Netz. Aufgefallen ist das nur daran, dass die Spannungen ueber zwei
            // Zyklen hinweg auf die zweite Nachkommastelle gleich blieben.
            //
            // ⚠️ Kurze Pause ZWISCHEN den Abfragen: dicht aufeinander folgende Anfragen
            // laesst das Geraet gelegentlich unbeantwortet.
            int gelesen = 0;
            int d;

            d = sma_abfrage(0x5380, 0x00251E00, 0x00251EFF);
            if (d > 0) { sma_auswerten(0x251E, d); gelesen = gelesen + 1; }
            else       { addLog("SMA: DC-Leistung ohne Antwort"); }
            delay(500);

            d = sma_abfrage(0x5380, 0x00451F00, 0x00451FFF);
            if (d > 0) { sma_auswerten(0x451F, d); gelesen = gelesen + 1; }
            else       { addLog("SMA: DC-Spannung ohne Antwort — die Spannungen unten sind ALT"); }
            delay(500);

            d = sma_abfrage(0x5100, 0x00263F00, 0x00263FFF);
            if (d > 0) { sma_auswerten(0x263F, d); gelesen = gelesen + 1; }
            else       { addLog("SMA: AC-Leistung ohne Antwort"); }

            // Der Zyklus zaehlt nur als gelungen, wenn die DC-Leistung kam — sie ist
            // der eigentliche Zweck (liefern beide Tracker?). Ohne sie ist das Alter
            // im UDP-Paket eine Luege.
            if (gelesen > 0) { ok = 1; }
            if (gelesen < 3) {
                sprintf(spur, "SMA: nur %d von 3 Abfragen beantwortet", gelesen);
                addLog(spur);
            }
        } else {
            addLog("SMA: Anmeldung abgelehnt — Kennwort?");
        }
    } else {
        addLog("SMA: Begruessung fehlgeschlagen");
    }

    // ⚠️ Nicht nur trennen, sondern den STAPEL ABBAUEN. Bluedroid Classic belegt rund
    // 85 KB Heap, solange es laeuft, und gibt sie von selbst nie zurueck. Bei einer
    // Messung alle 15 Minuten waere das Geraet die uebrige Zeit speicherarm — und der
    // naechste Slot-Neustart scheitert dann an der Speicheranforderung, gemeldet als
    // "Stack overflow" (die OOM-Pfade des Laders geben diesen Fehlercode zurueck).
    // sppInit() in der naechsten Messung faehrt den Stapel in etwa 300 ms wieder hoch.
    sppClose();
    sppDeinit();
#if SPUR
    hp("nach sppDeinit");
#endif

    if (ok) {
        letzte_zeit = millis();
        lauf_zaehler = lauf_zaehler + 1;
        addLog("SMA: A %d W / %d.%02d V, B %d W / %d.%02d V, AC %d W",
               dc_a, u_a / 100, u_a % 100, dc_b, u_b / 100, u_b % 100, ac_w);
    } else {
        fehler_zaehler = fehler_zaehler + 1;
    }
    return ok;
}

// ---------------------------------------------------------------------------------
// TaskLoop — hier laufen die langen Wartezeiten, nicht im Hauptlauf.
// Kein while(1): TaskLoop wird von der Firmware immer wieder aufgerufen, der Zustand
// steht in globalen Variablen. delay() ist hier erlaubt und gibt die VM zwischendurch
// frei, damit Webseite und Kommandos ansprechbar bleiben.
// ---------------------------------------------------------------------------------
// ---------------------------------------------------------------------------------
// Werte ins lokale Netz geben — UDP-Multicast auf 239.255.255.250:1999,
// vertraeglich mit dem Scripter-Protokoll. Gegenstelle liest sie mit
//     float w[6];  int n = udpRecvArray("sma", w, 6);
//
// ⚠️ udpSendArray erwartet FLOAT. Unsere Messwerte sind Ganzzahlen (Spannungen in
// Hundertstel Volt), deshalb wird hier in die lesbaren Einheiten umgerechnet — sonst
// muesste jede Gegenstelle raten, was 19061 bedeutet.
//
// ⚠️ Gesendet wird JEDE MINUTE, gemessen nur alle TAKT_S. Dazwischen gehen also
// dieselben Werte erneut hinaus. Das ist Absicht: der Wechselrichter hat genau EINEN
// Verbindungsplatz, oefter messen geht nicht — aber eine Gegenstelle, die gerade erst
// startet, soll nicht bis zu einer Viertelstunde auf das erste Paket warten.
//
//   [0] Tracker A, Watt          [3] Tracker B, Volt
//   [1] Tracker B, Watt          [4] AC-Wirkleistung, Watt
//   [2] Tracker A, Volt          [5] ALTER der Messung in Sekunden, -1 = noch keine
//
// Das Alter statt eines Zeitstempels: die Gegenstelle sieht damit sofort, ob die Werte
// frisch sind, ohne die Uhren abgleichen zu muessen. Die Summe der beiden Tracker kann
// sie selbst bilden, das Alter nicht.
float gsma[6];
int naechster_funk;     // millis() der naechsten Sendung

void werte_senden() {
    gsma[0] = dc_a;
    gsma[1] = dc_b;
    gsma[2] = u_a / 100.0;
    gsma[3] = u_b / 100.0;
    gsma[4] = ac_w;
    if (lauf_zaehler > 0) { gsma[5] = (millis() - letzte_zeit) / 1000.0; }
    else                  { gsma[5] = -1.0; }
    udpSendArray(UDP_NAME, gsma, 6);
}

int naechste_messung;   // millis() der naechsten Messung
int startfrist;         // vor dieser Zeit gar nichts tun

void TaskLoop() {
    if (millis() < startfrist) { delay(500); return; }   // Geraet erst hochfahren lassen
    if (sofort_messen || millis() > naechste_messung) {
        sofort_messen = 0;
        sma_messen();
        naechste_messung = millis() + TAKT_S * 1000;
        naechster_funk   = 0;            // direkt nach einer Messung sofort senden
    }
    if (millis() > naechster_funk) {
        werte_senden();
        naechster_funk = millis() + UDP_TAKT_S * 1000;
    }
    delay(500);               // haeufig genug, damit SMAREAD schnell greift
}

// ---------------------------------------------------------------------------------
// Konsolenkommando: SMAREAD  — sofort messen
// ---------------------------------------------------------------------------------
void Command(char cmd[]) {
    // ⚠️ Strings mit strcmp vergleichen, nicht mit == (das vergleicht Adressen).
    // ⚠️ JEDER Zweig muss responseCmnd rufen, auch der unbekannte — sonst meldet
    //    Tasmota {"Command":"Error"}, obwohl das Skript sauber gelaufen ist.
    if (strcmp(cmd, "READ") == 0) {
        // ⚠️ HIER NICHT MESSEN. Command laeuft synchron mit begrenztem
        // Anweisungsbudget; sma_messen() wartet bis zu 25 s auf die Verbindung.
        // Das Kommando bliebe ohne Antwort. Also nur anstossen.
        sofort_messen = 1;
        responseCmnd("Messung angestossen");
    } else {
        char antw[96];
        sprintf(antw, "A=%d W B=%d W AC=%d W Laeufe=%d Fehler=%d",
                dc_a, dc_b, ac_w, lauf_zaehler, fehler_zaehler);
        responseCmnd(antw);
    }
}

// ---------------------------------------------------------------------------------
// Anzeige auf der Tasmota-Hauptseite
// ---------------------------------------------------------------------------------
void WebCall() {
    char z[160];
    sprintf(z, "{s}SMA Tracker A{m}%d W / %d.%02d V{e}", dc_a, u_a / 100, u_a % 100);
    webSend(z);
    sprintf(z, "{s}SMA Tracker B{m}%d W / %d.%02d V{e}", dc_b, u_b / 100, u_b % 100);
    webSend(z);
    sprintf(z, "{s}SMA DC / AC{m}%d W / %d W{e}", dc_a + dc_b, ac_w);
    webSend(z);
    if (dc_a + dc_b > 50) {
        if (dc_a == 0 || dc_b == 0) {
            webSend("{s}SMA{m}<b>ein Tracker liefert nichts</b>{e}");
        }
    }
}

// ⚠️ WIRD BEIM STOPPEN DES SLOTS GERUFEN — und das ist hier keine Kosmetik.
// Bluedroid belegt rund 85 KB Heap, solange es laeuft, und ueberlebt einen Slot-Wechsel.
// Ohne diesen Abbau startet das neu geladene Skript in einen Speicher von ~20 KB und
// scheitert schon an der ersten Feldanforderung — gemeldet als "Stack overflow", was
// vollstaendig in die Irre fuehrt. Reihenfolge beim Hochladen: TinyCStop (ruft CleanUp,
// gibt Bluetooth frei) → TinyCRun (laedt in einen aufgeraeumten Speicher).
void CleanUp() {
    sppClose();
    sppDeinit();
}

int main() {
    marke = 0;
    // ⚠️ Zeichenketten gehoeren mit strcpy in ein char[] — `feld = "text"` endet
    // in einer Bereichsueberschreitung (Laufzeitfehler 9).
    strcpy(adr_text, SMA_ADR);
    int ai = 0;
    while (ai < 6) { adr_b[ai] = sma_adr_byte(ai); ai = ai + 1; }
    addCommand("SMA");              // Konsole: SMAREAD stoesst eine Messung an
    startfrist       = millis() + 25000;   // Netz/Funk hoch UND einen evtl. noch
                                           // belegten Platz beim Geraet frei werden lassen
    naechste_messung = 0;                  // danach sofort die erste Messung
    naechster_funk   = 0;
    addLog("SMA: bereit fuer %s (erstes Adressbyte %02X)", adr_text, sma_adr_byte(0));
    return 0;
}