Skip to content

gm24g_lamp.tc

gm24g_lamp.tc — GM2.4G-Deckenlampe steuern (nRF24L01 als LT8900-Ersatz)

Source on GitHub

// ============================================================================
// gm24g_lamp.tc — GM2.4G-Deckenlampe steuern (nRF24L01 als LT8900-Ersatz)
// ============================================================================
//
// Sender-App: Bedienfeld auf der Tasmota-Hauptseite, der Fernbedienung
// nachempfunden, plus Konsolenbefehl LAMPTX. Alle 14 Tasten der Original-
// fernbedienung sind decodiert. Vorgeschichte: das Protokoll wurde am
// 2026-07-27/28 aus einem SPI-Mitschnitt der Fernbedienung, HackRF-Messungen
// und einem nRF24-Mithoerbetrieb erschlossen. Der Mithoerteil ist hier raus —
// er steckt in der git-Historie unter examples/lt8900_sniff.tc, falls das
// Protokoll je erneut vermessen werden muss.
//
// ── PROTOKOLL (gemessen, nicht geraten) ───────────────────────────────────
// Die Lampe hoert auf einen LT8900, GFSK, 1 Mbit/s. Kanaele: LT8900 4/39/74.
// LT8900 rechnet 2402+n, der nRF24 dagegen 2400+n — auf dem nRF24 sind das
// also die Kanaele 6/41/76. (2441.02 MHz per HackRF nachgemessen.)
//
// Auf der Luft:   55 55 55 55 | 8A 85 5A AA | Nutzlast bitgedreht | CRC
// Dekodiert:      07 | 5A 4B 07 b4 CC VV | SUM
//                 07       = Laengenbyte
//                 5A 4B 07 = Kennung des Fernbedienungspaars (siehe unten,
//                            es gibt hier ZWEI verschiedene)
//                 b4       = zuletzt ANGEWAEHLTE Zone, nicht die Zone des
//                            Befehls. Nur die EIN-Tasten setzen sie; AUS- und
//                            Ringtasten tragen sie unveraendert weiter.
//                            (Gemessen 2026-07-29: nach vier EIN-Tasten
//                            trugen alle vier AUS-Tasten b4=04, und Ringtasten
//                            trugen b4=03, weil davor Zone 3 gewaehlt war.)
//                            Die Zone des Befehls steckt allein im Nibble
//                            von CC. Wir senden b4 = Zone des Befehls; das
//                            entspricht dem Normalfall „erst EIN, dann AUS
//                            derselben Zone" und funktioniert nachweislich.
//                 CC       = Tastencode; Bit 0x10 = Wiederholung
//                 VV       = Zaehler, steigt je Tastendruck
//                 SUM      = (Summe der sieben Bytes) & 0xFF
// CRC-16/CCITT, Poly 0x1021, Init 0x0000, MSB-first, ueber die LUFT-Bytes
// (also die bitgedrehten) inklusive Laengenbyte, angehaengt als hi,lo und
// NICHT gedreht.
//
// ── WARUM DAS MIT EINEM nRF24 GEHT ────────────────────────────────────────
// Beide Chips senden GFSK mit 1 Mbit/s. Man legt Praeambel + Syncword des
// LT8900 in das ADRESSFELD des nRF24, schaltet dessen CRC ab und dreht die
// Bitreihenfolge der Nutzlast um.
//   Quelle: DIY-Multiprotocol-TX-Module/Multiprotocol/NRF24l01_SPI.ino
//
// ── DREI FALLEN, die je eine Runde gekostet haben ─────────────────────────
//  1. RF_SETUP 0x0E ist 2 Mbit/s, NICHT 1 Mbit/s (Bit 3 = RF_DR_HIGH).
//     Richtig ist 0x06. Adresse und Kanal stimmten, die Symbolrate nicht.
//  2. CE muss >=10 us high bleiben. Zwei digitalWrite direkt hintereinander
//     sind zu kurz — der Chip sendet dann GAR NICHT, ohne Fehlermeldung.
//     Das delay(1) in tx_frame() ist Absicht und liefert nebenbei die
//     richtige Kadenz (~1,1 ms je Rahmen; das Original braucht 1,135 ms).
//  3. Nicht pro Rahmen konfigurieren. Erst wurde der Chip fuer JEDEN Rahmen
//     neu aufgesetzt (Power-Down, alle Register, Power-Up, 2 ms warten) —
//     rund 3 ms statt 1,1 ms und sporadische Aussetzer ("manchmal muss man
//     zweimal druecken"). Einmal pro Burst: tx_setup(), dann je Rahmen nur
//     Kanal + Nutzlast + CE-Impuls.
//
// ── VERDRAHTUNG (.156 „Mini2", ESP32-S3, ESP32-Bridge) ────────────────────
//   Vorgaenger .177 ist am 2026-07-29 bei einem Firmware-Update gestorben
//   (nach dem OTA nicht mehr erreichbar, seriell nur noch Fehler). Die
//   Funkchips wurden auf ein Ersatzgeraet umgesteckt. Die drei SPI-Pins sind
//   im Tasmota-Template zu setzen, ein frisch geflashtes Geraet hat sie NICHT:
//       GPIO9 = 704 (SPI MOSI)  GPIO10 = 736 (SPI CLK)  GPIO11 = 672 (SPI MISO)
//   Tasmota-UI belegt damit:    9 = MOSI, 10 = SCLK, 11 = MISO
//   Hier gesetzt:               7 = CSN des nRF24L01
//   Hier gesetzt:               8 = CE des nRF24L01
//   ⚠️ 10–100 µF Elko direkt am Modul zwischen VCC und GND, sonst bricht die
//      Versorgung ein und das Modul verhaelt sich sporadisch.
//   ⚠️ Der CC1101 (CS 13) haengt am SELBEN MISO. Sein CS muss aktiv auf HIGH
//      gelegt werden, sonst treibt er die Leitung mit und der Bus liefert nur
//      Muell. "Nicht anfassen" reicht NICHT — ein undefinierter Eingang ist
//      nicht dasselbe wie abgewaehlt.
//
// ── BEFEHLE ───────────────────────────────────────────────────────────────
//   Tasmota haengt das Unterkommando OHNE Leerzeichen an den Praefix an.
//   LAMPTX <hex>  -> Taste senden. alle=5/9  Z1=8/B  Z2=D/3  Z3=7/A  Z4=2/6
//                    heller=C  dunkler=4  warm=E  kalt=F
//   LAMPRC <n>    -> Fernbedienung waehlen (welche Lampengruppe zuhoert)
//   LAMPLEARN     -> neue Fernbedienung anlernen: danach 60 s lang eine
//                    beliebige Taste auf ihr druecken, NAH am Modul
//   LAMPLIST      -> bekannte Kennungen + Anlernstatus (+ RX_DR-Zaehler)
//   LAMPFORGET n  -> Kennung n loeschen
//
//   Diagnose bei „alle Register FF" — in dieser Reihenfolge, jeder Schritt
//   halbiert den Suchraum:
//   LAMPCC        -> CC1101 am SELBEN Bus lesen. VERSION 04/14 = CLK/MOSI/MISO
//                    und die SPI-Schicht sind bewiesen in Ordnung.
//   LAMPPINS      -> schalten CSN und CE ueberhaupt? (je 0 und 1 erwartet)
//   LAMPHI        -> CC1101 hart abwaehlen, dann lesen — schliesst aus, dass er
//                    den geteilten MISO festhaelt.
//   LAMPSLOW      -> dieselben Register mit 1 MHz statt 4. Unterscheidet
//                    Signalqualitaet von Unterbrechung.
//   LAMPMAN       -> ueber SPI-Slot 0 mit CS von Hand lesen, genau wie LAMPCC.
//                    Trennt einen Fehler der Slot-/CS-Verwaltung von Hardware.
//   ⚠️ Bestehen ALLE diese Tests und es bleibt FF, waehrend Senden noch geht:
//      Senden braucht MISO nicht. Dann ist der MISO-Ausgang des Moduls hin —
//      bei billigen nRF24-Klonen die wahrscheinlichste Erklaerung. Folge:
//      Steuern funktioniert weiter, ANLERNEN nicht.
//
//   Diagnose, falls das Anlernen nichts findet — in dieser Reihenfolge:
//   ⚠️ Anlernen findet nichts, obwohl die Register stimmen? Dann rastet das
//      Modul auf die Adressbreite nicht ein. Das Anlernen faehrt 4/5/3 Byte
//      durch; die getroffene Breite steht im Log und in LAMPLIST.
//   LAMPCC        -> ZUERST bei „alle Register FF": den CC1101 am SELBEN Bus
//                    auslesen. Antwortet er (VERSION 04/14), sind CLK/MOSI/MISO
//                    bewiesen in Ordnung und der Fehler sitzt beim nRF24.
//   LAMPNOISE     -> 2 s mit 3-Byte-Adresse lauschen. >0 Pakete = Antenne,
//                    MISO und Empfangszweig arbeiten; 0 = Empfang defekt.
//   LAMPREG       -> Register UND Empfangsadresse zurueckliegen. Waehrend des
//                    Anlernens erlaubt, weil es nichts umkonfiguriert.
//   LAMPRAW       -> RX_DR-Zaehler + erstes Rohpaket des letzten Versuchs.
//                    Trennt "es kommt nichts an" von "es kommt an, wird aber
//                    verworfen" — genau daran hing es beim Geraetewechsel.
//   LAMPTEST      -> nRF24-Register zuruecklesen (prueft Bus und Modul)
//   LAMPONE       -> EINEN Rahmen senden und TX_DS im Status pruefen.
//                    Der schnellste Beweis, dass wirklich gesendet wird —
//                    viel schneller als eine HackRF-Gegenmessung.
//   ⚠️ Unterkommandos duerfen NICHT auf einer Ziffer enden: Tasmota liest
//      eine abschliessende Ziffer als Geraeteindex (wie POWER1). Deshalb
//      LAMPONE und nicht LAMPT1.
// ============================================================================

#define CSN_PIN   7
#define CE_PIN    8          // CE verloetet auf GPIO 8
#define CC_CS_PIN 13         // CS des CC1101 am SELBEN Bus — muss HIGH sein
#define SLOT      1
#define LED_PIN     21      // onboard WS2812 RGB status LED (board-specific: Lolin S3 Mini = GPIO21)

// nRF24L01 Befehle / Register
#define N_R_REG    0x00
#define N_W_REG    0x20
#define N_FLUSH_TX 0xE1
#define N_W_TX_PL  0xA0
#define N_CONFIG   0x00
#define N_EN_AA    0x01
#define N_AW       0x03
#define N_RF_CH    0x05
#define N_RF_SET   0x06
#define N_STATUS   0x07
#define N_FIFO     0x17
#define RF_1MBIT   0x06      // NICHT 0x0E — das waeren 2 Mbit/s

char spi[40];
char line[220];
int  tx_total;   // gesendete Rahmen insgesamt
int  tx_seq;     // Zaehler VV, steigt je Tastendruck
int  ui_cmd;     // von den Web-Knoepfen gesetzt: Befehlsnibble, 0 = nichts
int  ui_rc;      // Fernbedienungswahl aus dem Web: 1 oder 2, 0 = nichts
int  tx_pend;    // -1 = nichts zu senden, sonst Befehlsnibble
int  last_sent;  // zuletzt gesendetes Nibble, -1 = noch nichts
int  last_ms;    // millis() beim letzten Senden

// ── ZWEI FERNBEDIENUNGEN ──────────────────────────────────────────────────
// Jeder Rahmen fuehrt eine 3-Byte-Kennung des Fernbedienungspaars mit. Die
// Lampen sind je auf EINE Fernbedienung angelernt und ignorieren alles andere.
// Am 2026-07-29 gemessen (Empfaenger auf Laenge+Pruefsumme verankert statt auf
// eine fest verdrahtete Kennung, sonst sieht man die zweite gar nicht):
//
//     Fernbedienung 1   5A 4B 07     Fernbedienung 2   5A 43 72
//
// Die Tastencodes sind bei BEIDEN identisch — Zone 1 EIN ist ueberall CC=08.
// Nur die Kennung unterscheidet sie. Das war die Ursache dafuer, dass Zone 2
// aus TinyC nichts bewirkte, mit der Fernbedienung aber einwandfrei ging:
// gesendet wurde die richtige Taste an die falsche Lampengruppe.
//
// NICHT beide Kennungen nacheinander senden — Zone 2 an Fernbedienung 1 und
// Zone 2 an Fernbedienung 2 sind VERSCHIEDENE Lampen; das schaltete beide.
// Die Kennungen sind NICHT mehr fest eingebaut — sie werden ANGELERNT.
// Wer dieses Skript auf eigener Hardware benutzt, hat andere Fernbedienungen
// und koennte eine fest verdrahtete Kennung nicht ermitteln, ohne sich den
// Empfaenger aus der git-Historie zu holen und eine HackRF-Messung
// nachzuvollziehen. Genau das nimmt LAMPLEARN bzw. das Untermenue
// „Fernbedienung anlernen" ab: Knopf druecken, an der neuen Fernbedienung eine
// beliebige Taste betaetigen, fertig.
//
// Ablage in `persist` statt in einer eigenen Datei: seit dem namensbasierten
// PVS3 wandern Eintraege beim Neuuebersetzen ueber ihren NAMEN mit, ein
// Hinzufuegen oder Umsortieren wirft die gelernten Kennungen also nicht mehr
// weg. persist-Initialisierer werden allerdings verworfen — alles startet auf
// 0, deshalb saet main() die Voreinstellung erst, wenn rc_cnt == 0 ist.
#define RC_MAX     4
#define LEARN_MS   60000         // Wartezeit auf einen Tastendruck.
                                 // 20 s waren zu knapp: bis die Aufforderung
                                 // gelesen und die Fernbedienung geholt war,
                                 // lief das Fenster ab und die Meldung
                                 // "nichts empfangen" sah wie ein Defekt aus.

// ⚠️ USE_PERSIST BRAUCHT TinyC >= 1.6.43. Auf .156 (1.6.44) VERIFIZIERT:
// Slot laedt, Auswahl ueberlebt den Slot-Neustart, .pvs wird angelegt.
// Auf 1.6.42 fuehrt SCHON DIE DEKLARATION einer persist-Variablen zum harten
// Neustart des ESP — nicht zu einem VM-Fehler, das Geraet bootet weg. Am
// 2026-07-29 auf .177 (Release 1.6.42, per /tc_api?cmd=status) mit einem
// Sechszeiler nachgestellt:
//     persist int pcount;
//     int main() { pcount = pcount + 1; addCommand("PVT"); return 0; }
// Es entsteht nicht einmal eine .pvs — es kracht beim LADEN, also beim
// Oeffnen der noch nicht vorhandenen Datei, nicht beim Speichern.
// Die persist-Ueberholung (namensbasiertes PV3, Absturzkorrekturen) kam mit
// 1.6.43. Auf einer aelteren Firmware also AUSKOMMENTIEREN, sonst bootet das
// Geraet weg; ohne die Zeile laufen die Kennungen nur bis zum naechsten
// Slot-Neustart (die beiden gemessenen kommen als Voreinstellung zurueck,
// angelernte nicht).
//
// ⚠️ ZUM EINSCHALTEN DIE NAECHSTE ZEILE EINKOMMENTIEREN, nicht auf 1 setzen:
// TinyCs `#if NAME` wertet den WERT NICHT AUS, es prueft nur, ob der Name
// definiert ist — `#define USE_PERSIST 0` nimmt trotzdem den Ja-Zweig (am
// 2026-07-29 am Praeprozessor nachgestellt). Nur `#ifdef`/`#ifndef` arbeiten
// hier zuverlaessig.
#define USE_PERSIST

#ifdef USE_PERSIST
persist int rc_cnt;          // wie viele Fernbedienungen bekannt sind
persist int rc_sel;          // 0..rc_cnt-1
persist int rc_i1[RC_MAX];
persist int rc_i2[RC_MAX];
persist int rc_i3[RC_MAX];
#else
int rc_cnt;
int rc_sel;
int rc_i1[RC_MAX];
int rc_i2[RC_MAX];
int rc_i3[RC_MAX];
#endif

// Einziger Ort, an dem gesichert wird — so kostet das Umschalten oben eine
// Zeile statt einer Suche durch die Datei.
void rc_save() {
#ifdef USE_PERSIST
    saveVars();
#endif
}

int  learn_state;   // 0 = aus, 1 = lauscht
int  learn_until;   // millis(), bis wann
int  learn_res;     // 0 = nichts, 1 = neu gelernt, 2 = war schon bekannt,
                    // 3 = Abbruch/Zeit abgelaufen, 4 = kein Platz frei
int  ui_learn;      // aus dem Web: 1 = anlernen, 2 = abbrechen, 10+n = loeschen
// Diagnose: trennt „es kommt nichts an" von „es kommt an, wird aber verworfen".
int  learn_rx;      // wie oft RX_DR waehrend des Anlernens gesetzt war
int  learn_raw[16]; // erstes Rohpaket, bitgedreht
int  learn_have;    // 1 = learn_raw gefuellt
// Bestaetigungszaehler: erst nach mehreren gleichen Kennungen uebernehmen.
int  cand1;
int  cand2;
int  cand3;
int  cand_n;

// Befehlsnibble -> Klartext. Bewusst if/else statt Ternaer: String-Ternaere
// scheiterten in TinyC lange still zur Laufzeit (im Codegen behoben am
// 2026-07-28, aber alte Firmware/IDE im Feld hat den Fix nicht).
// Vierzehn Tasten, vierzehn frei vergebene Codes — kein arithmetisches Muster,
// eine reine Tabelle. Byte 4 fuehrt die Zone redundant mit (00=alle, 01..04).
void btn_name(int nib, char out[]) {
    if (nib == 0x4)      { strcpy(out, "dunkler"); }
    else if (nib == 0x5) { strcpy(out, "EIN alle"); }
    else if (nib == 0x9) { strcpy(out, "AUS alle"); }
    else if (nib == 0xC) { strcpy(out, "heller"); }
    else if (nib == 0xE) { strcpy(out, "warm"); }
    else if (nib == 0xF) { strcpy(out, "kalt"); }
    else if (nib == 0x8) { strcpy(out, "EIN Zone 1"); }
    else if (nib == 0xB) { strcpy(out, "AUS Zone 1"); }
    else if (nib == 0xD) { strcpy(out, "EIN Zone 2"); }
    else if (nib == 0x3) { strcpy(out, "AUS Zone 2"); }
    else if (nib == 0x7) { strcpy(out, "EIN Zone 3"); }
    else if (nib == 0xA) { strcpy(out, "AUS Zone 3"); }
    else if (nib == 0x2) { strcpy(out, "EIN Zone 4"); }
    else if (nib == 0x6) { strcpy(out, "AUS Zone 4"); }
    else                 { strcpy(out, "unbekannt"); }
}

// Nibble -> zugehoeriges Byte 4 (Zone)
int nib_zone(int nib) {
    if (nib == 0x8) { return 1; }
    if (nib == 0xB) { return 1; }
    if (nib == 0xD) { return 2; }
    if (nib == 0x3) { return 2; }
    if (nib == 0x7) { return 3; }
    if (nib == 0xA) { return 3; }
    if (nib == 0x2) { return 4; }
    if (nib == 0x6) { return 4; }
    return 0;
}

// ── nRF24-Grundfunktionen ─────────────────────────────────────────────────
void nrf_wr(int reg, int val) {
    spi[0] = N_W_REG | (reg & 0x1F);
    spi[1] = val & 0xFF;
    spiTransfer(SLOT, spi, 2, 1);
}

int nrf_rd(int reg) {
    spi[0] = N_R_REG | (reg & 0x1F);
    spi[1] = 0;
    spiTransfer(SLOT, spi, 2, 1);
    return spi[1] & 0xFF;
}

void nrf_cmd(int c) {
    spi[0] = c & 0xFF;
    spiTransfer(SLOT, spi, 1, 1);
}

// Bits eines Bytes spiegeln — LT8900 sendet in umgekehrter Bitfolge zum nRF24
int bitrev(int b) {
    int r = 0;
    int i = 0;
    while (i < 8) {
        r = (r << 1) | ((b >> i) & 1);
        i = i + 1;
    }
    return r & 0xFF;
}

// ── SENDEN ────────────────────────────────────────────────────────────────
int lt_crc(char d[], int n) {
    int c = 0;
    int i = 0;
    while (i < n) {
        c = c ^ ((d[i] & 0xFF) << 8);
        int b = 0;
        while (b < 8) {
            if (c & 0x8000) { c = ((c << 1) ^ 0x1021) & 0xFFFF; }
            else            { c = (c << 1) & 0xFFFF; }
            b = b + 1;
        }
        i = i + 1;
    }
    return c & 0xFFFF;
}

// Sender EINMAL pro Burst aufsetzen (siehe Falle 3 im Kopf).
void tx_setup() {
    if (CE_PIN >= 0) { digitalWrite(CE_PIN, 0); }
    nrf_wr(N_CONFIG, 0x00);
    nrf_wr(N_EN_AA, 0x00);
    nrf_wr(N_AW, 0x03);
    nrf_wr(N_RF_SET, RF_1MBIT);
    // TX_ADDR: Die Fernbedienung sendet VIER Praeambelbytes 0x55 vor dem
    // Syncword. Der nRF24 stellt nur EINES voran; laege das ganze Syncword in
    // der Adresse, kaemen nur zwei zusammen — der LT8900 in der Lampe rastet
    // dann nicht zuverlaessig ein (per HackRF nachgewiesen). Deshalb die
    // Grenze verschieben: Adresse = 55 55 55 8A 85, der Rest des Syncwords
    // (5A AA) wandert an den Anfang der Nutzlast.
    spi[0] = N_W_REG | 0x10;
    spi[1] = 0x85; spi[2] = 0x8A; spi[3] = 0x55; spi[4] = 0x55; spi[5] = 0x55;
    spiTransfer(SLOT, spi, 6, 1);
    nrf_cmd(N_FLUSH_TX);
    nrf_wr(N_STATUS, 0x70);
    nrf_wr(N_CONFIG, 0x02);           // PWR_UP, PRIM_RX=0, CRC AUS
    delay(2);                         // einmalig einschwingen lassen
}

// einen Rahmen senden. Setzt nur Kanal, Nutzlast und den CE-Impuls.
void tx_frame(int nib, int vv, int b4, int chan, int flag) {
    int cc = (nib & 0x0F);
    if (flag) { cc = cc | 0x10; }
    int i1 = rc_i1[rc_sel];
    int i2 = rc_i2[rc_sel];
    int i3 = rc_i3[rc_sel];
    int sum = (0x07 + i1 + i2 + i3 + b4 + cc + vv) & 0xFF;
    char air8[10];
    air8[0] = bitrev(0x07); air8[1] = bitrev(i1); air8[2] = bitrev(i2);
    air8[3] = bitrev(i3);   air8[4] = bitrev(b4);
    air8[5] = bitrev(cc);   air8[6] = bitrev(vv);   air8[7] = bitrev(sum);
    int crc = lt_crc(air8, 8);

    nrf_wr(N_RF_CH, chan & 0x7F);
    nrf_cmd(N_FLUSH_TX);
    nrf_wr(N_STATUS, 0x70);
    // Nutzlast: Rest des Syncwords (5A AA) + 8 Luft-Bytes + CRC hi/lo
    spi[0] = N_W_TX_PL;
    spi[1] = 0x5A; spi[2] = 0xAA;
    int k = 0;
    while (k < 8) { spi[k+3] = air8[k]; k = k + 1; }
    spi[11] = (crc >> 8) & 0xFF;
    spi[12] = crc & 0xFF;
    spiTransfer(SLOT, spi, 13, 1);
    if (CE_PIN >= 0) {
        digitalWrite(CE_PIN, 1);
        delay(1);                     // CE MUSS >=10 us hoch bleiben; zwei
        digitalWrite(CE_PIN, 0);      // digitalWrite hintereinander sind zu kurz
    }                                 // und der Chip sendet dann GAR NICHT.
    tx_total = tx_total + 1;
}

// ── ANLERNEN: EMPFANGEN ───────────────────────────────────────────────────
// Zum Anlernen muss der Chip kurz auf Empfang. Senden und Empfangen geht mit
// EINEM nRF24 nicht gleichzeitig, das Anlernen ist deshalb ein eigener
// Zustand, waehrend dessen nicht gesendet wird.
//
// Adresse = Praeambelrest + Syncword, in Registerreihenfolge (also umgekehrt
// zur Sendereihenfolge). Genau diese Werte sind am 2026-07-28 als einzige
// funktionierende Kombination herausgemessen worden.
// Adressbreite, die gerade probiert wird (5, 4 oder 3 Byte).
// Auf der Luft steht ... 55 8A 85 5A AA | Nutzlast. Der nRF24 vergleicht die
// Bytes UNMITTELBAR vor der Nutzlast; in Registerreihenfolge sind das
// AA, 5A, 85, 8A, 55 — eine kuerzere Adresse nimmt einfach die ersten w davon.
// Warum ueberhaupt kuerzer: manche Klone rasten auf die volle 5-Byte-Folge
// nicht ein, weil sie Praeambel und Adressbeginn strenger auswerten. Statt
// gemu fuer jede Variante einzeln druecken zu lassen, faehrt das Anlernen die
// Breiten im Wechsel durch.
// Vorgabe 4 — auf dem Modul vom 2026-07-29 rastet NUR 4 Byte ein, bei 5 blieb
// RX_DR auf 0, obwohl die Register nachweislich stimmten. Das alte Modul
// brauchte 5. Deshalb bleibt der Wechsel drin: beim naechsten Tausch kann es
// wieder anders sein, und ein Fenster probiert alle drei durch.
int rx_aw = 4;

void rx_setup() {
    if (CE_PIN >= 0) { digitalWrite(CE_PIN, 0); }
    nrf_wr(N_CONFIG, 0x00);
    nrf_wr(N_EN_AA, 0x00);
    nrf_wr(0x02, 0x01);              // EN_RXADDR: nur Pipe 0
    nrf_wr(N_AW, (rx_aw - 2) & 0x03);   // 5, 4 oder 3 Byte
    nrf_wr(N_RF_CH, 41);             // Lampenkanal 39 + 2; dort landen je
    nrf_wr(N_RF_SET, RF_1MBIT);      // Tastendruck 30 Rahmen, das reicht
    nrf_wr(0x11, 32);                // RX_PW_P0: feste Nutzlastlaenge
    spi[0] = N_W_REG | 0x0A;         // RX_ADDR_P0
    spi[1] = 0xAA; spi[2] = 0x5A; spi[3] = 0x85; spi[4] = 0x8A; spi[5] = 0x55;
    spiTransfer(SLOT, spi, rx_aw + 1, 1);
    nrf_cmd(0xE2);                   // FLUSH_RX
    nrf_wr(N_STATUS, 0x70);
    nrf_wr(N_CONFIG, 0x03);          // PWR_UP | PRIM_RX, CRC im nRF24 AUS
    delay(2);
    if (CE_PIN >= 0) { digitalWrite(CE_PIN, 1); }
}

// Ein Paket abholen und auswerten. Rueckgabe 1, wenn ein gueltiger Rahmen
// erkannt wurde; die Kennung steht dann in got_i1..got_i3.
//
// ⚠️ NICHT auf eine bekannte Kennung matchen. Der urspruengliche Empfaenger
// suchte hart nach 5A 4B 07 und meldete sauber „0 Pakete", waehrend die
// zweite Fernbedienung munter sendete — beim Anlernen ist die gesuchte
// Kennung ja gerade unbekannt. Anker ist die STRUKTUR: Laengenbyte 0x07,
// danach sechs Bytes und eine Summenpruefsumme darueber.
int got_i1;
int got_i2;
int got_i3;

int rx_poll() {
    int st = nrf_rd(N_STATUS);
    if (!(st & 0x40)) { return 0; }          // kein RX_DR
    learn_rx = learn_rx + 1;
    spi[0] = 0x61;                           // R_RX_PAYLOAD
    int w = 1;
    while (w <= 32) { spi[w] = 0; w = w + 1; }
    spiTransfer(SLOT, spi, 33, 1);
    nrf_wr(N_STATUS, 0x70);
    nrf_cmd(0xE2);                           // FLUSH_RX
    char rxb[34];
    w = 0;
    while (w < 32) { rxb[w] = bitrev(spi[w+1] & 0xFF); w = w + 1; }
    if (!learn_have) {
        int c = 0;
        while (c < 16) { learn_raw[c] = rxb[c] & 0xFF; c = c + 1; }
        learn_have = 1;
    }
    // ⚠️ DAS LAENGENBYTE NICHT AUS DER LUFT VERLANGEN.
    // Auf .156 kommt das erste Nutzlastbyte als 0x00 an, obwohl der Sender
    // 0x07 schickt (auf .177 kam es korrekt — offenbar eine Eigenheit des
    // Adress-/Nutzlast-Uebergangs bei diesem Chip). Der REST des Rahmens ist
    // fehlerfrei, die Pruefsumme des Senders rechnet mit 0x07. Also 0x07 als
    // Protokollkonstante einsetzen statt es als Anker zu fordern — sonst wird
    // JEDER gueltige Rahmen verworfen, und es sieht aus, als kaeme nichts an.
    // Gemessen: 1531 Pakete empfangen, 0 erkannt.
    int k = 0;
    while (k < 24) {
        int t = (0x07 + rxb[k+1] + rxb[k+2] + rxb[k+3]
               + rxb[k+4] + rxb[k+5] + rxb[k+6]) & 0xFF;
        if (t == (rxb[k+7] & 0xFF)) {
            int i1 = rxb[k+1] & 0xFF;
            int i2 = rxb[k+2] & 0xFF;
            int i3 = rxb[k+3] & 0xFF;
            // Ohne das Laengenbyte als Anker traegt nur noch die Pruefsumme —
            // das trifft rein zufaellig etwa jedes 256. Fenster. Bei 25 Paketen
            // pro Sekunde waere das reichlich Rauschen. Deshalb erst nach DREI
            // gleichen Kennungen uebernehmen; ein echter Tastendruck sendet 30
            // Rahmen auf diesem Kanal, Rauschen wiederholt sich nicht.
            if (i1 == cand1) {
                if (i2 == cand2) {
                    if (i3 == cand3) { cand_n = cand_n + 1; }
                    else { cand1 = i1; cand2 = i2; cand3 = i3; cand_n = 1; }
                } else { cand1 = i1; cand2 = i2; cand3 = i3; cand_n = 1; }
            } else { cand1 = i1; cand2 = i2; cand3 = i3; cand_n = 1; }
            if (cand_n >= 3) {
                got_i1 = i1; got_i2 = i2; got_i3 = i3;
                return 1;
            }
            return 0;
        }
        k = k + 1;
    }
    return 0;
}

// Kennung einsortieren. Rueckgabe: 1 = neu, 2 = war schon bekannt, 4 = voll.
int rc_store(int i1, int i2, int i3) {
    int z = 0;
    while (z < rc_cnt) {
        if (rc_i1[z] == i1) {
            if (rc_i2[z] == i2) {
                if (rc_i3[z] == i3) { rc_sel = z; return 2; }
            }
        }
        z = z + 1;
    }
    if (rc_cnt >= RC_MAX) { return 4; }
    rc_i1[rc_cnt] = i1; rc_i2[rc_cnt] = i2; rc_i3[rc_cnt] = i3;
    rc_sel = rc_cnt;
    rc_cnt = rc_cnt + 1;
    rc_save();           // sofort sichern, nicht erst bei TinyCStop —
    return 1;            // ein Stromausfall soll das Anlernen nicht fressen
}

void rc_forget(int idx) {
    if (idx < 0) { return; }
    if (idx >= rc_cnt) { return; }
    int z = idx;
    while (z < rc_cnt - 1) {
        rc_i1[z] = rc_i1[z+1]; rc_i2[z] = rc_i2[z+1]; rc_i3[z] = rc_i3[z+1];
        z = z + 1;
    }
    rc_cnt = rc_cnt - 1;
    if (rc_sel >= rc_cnt) { rc_sel = rc_cnt - 1; }
    if (rc_sel < 0) { rc_sel = 0; }
    rc_save();
}

// Einen kompletten Tastendruck senden: 90 Rahmen ueber DREI Kanaele
// (LT8900 4/39/74 = nRF24 6/41/76), je 30 pro Kanal. Nur auf einem Kanal zu
// senden reichte fuer manche Befehle nicht.
void send_button(int nib) {
    int tx_ch[3];
    tx_ch[0] = 6; tx_ch[1] = 41; tx_ch[2] = 76;
    tx_setup();
    // Die Fernbedienung sendet BEIDE Varianten: erst einige Rahmen OHNE das
    // 0x10-Bit, dann die Mehrzahl MIT. Gemessen bei "Zone 1 AUS": 20x 0x0B
    // gegen 64x 0x1B, also grob ein Drittel ohne Flag. Offenbar wertet die
    // Lampe die Fassung ohne Flag als eigentlichen Tastendruck und die mit
    // Flag nur als Wiederholung — mit nur einer Variante schaltete sie nicht.
    int r = 0;
    while (r < 30) {
        int fl = 1;
        if (r < 10) { fl = 0; }
        int c2 = 0;
        while (c2 < 3) {
            tx_frame(nib, tx_seq, nib_zone(nib), tx_ch[c2], fl);
            c2 = c2 + 1;
        }
        r = r + 1;
    }
    tx_seq = (tx_seq + 1) & 0xFF;
    last_sent = nib;
    last_ms = millis();
}

// Senden laeuft in der eigenen Task, nicht im Tick: ein Burst dauert rund
// 100 ms und wuerde die Tasmota-Hauptschleife blockieren.
void TaskLoop() {
    if (ui_learn != 0) {
        int w = ui_learn;
        ui_learn = 0;
        if (w == 1) {
            learn_state = 1;
            learn_res = 0;
            learn_rx = 0; learn_have = 0;
            cand1 = -1; cand2 = -1; cand3 = -1; cand_n = 0;
            learn_until = millis() + LEARN_MS;
            rx_setup();
            addLog("gm24g: Anlernen laeuft — Taste auf der neuen Fernbedienung druecken");
        }
        if (w == 2) { learn_state = 0; learn_res = 3; tx_setup(); }
        if (w >= 10) { rc_forget(w - 10); }
    }
    // Anlernen hat Vorrang: solange der Chip auf Empfang steht, kann er nicht
    // senden. Die Schleife arbeitet in kurzen Scheiben, damit „Abbrechen"
    // und die Zeitgrenze zuegig greifen.
    if (learn_state == 1) {
        // Adressbreite alle ~3 s wechseln: 5 -> 4 -> 3 -> 5 ...
        // Reihenfolge 4 -> 5 -> 3: die wahrscheinlichste zuerst.
        if (rx_aw == 4)      { rx_aw = 5; }
        else if (rx_aw == 5) { rx_aw = 3; }
        else                 { rx_aw = 4; }
        rx_setup();
        int q = 0;
        while (q < 50) {
            if (rx_poll()) {
                learn_res = rc_store(got_i1, got_i2, got_i3);
                learn_state = 0;
                tx_setup();                      // zurueck auf Senden
                addLog("gm24g: Kennung %02X %02X %02X (%d) bei %d-Byte-Adresse",
                       got_i1, got_i2, got_i3, learn_res, rx_aw);
                return;
            }
            delay(1);
            q = q + 1;
        }
        if (millis() > learn_until) {
            learn_state = 0;
            learn_res = 3;
            tx_setup();
            addLog("gm24g: Anlernen abgelaufen — nichts empfangen");
        }
        return;
    }
    if (ui_rc != 0) {
        int n = ui_rc - 1;
        ui_rc = 0;
        if (n >= 0) { if (n < rc_cnt) { rc_sel = n; rc_save(); } }
        addLog("gm24g: Fernbedienung %d gewaehlt", rc_sel + 1);
    }
    if (ui_cmd != 0) {
        tx_pend = ui_cmd & 0x0F;
        ui_cmd = 0;
    }
    if (tx_pend >= 0) {
        int nb = tx_pend;
        tx_pend = -1;
        send_button(nb);
        char nm[16];
        btn_name(nb, nm);
        addLog("gm24g: %s gesendet (90 Rahmen, 3 Kanaele)", nm);
        return;
    }
    delay(20);
}

// ── Anzeige auf der Tasmota-Hauptseite ─────────────────────────────────────
void WebCall() {
    char nm[16];
    // Der Anlernstatus MUSS hier stehen, nicht im Panel: WebPage() wird nur
    // beim vollen Seitenaufbau gerendert, dieser Block dagegen bei jedem
    // Ajax-Refresh — nur so sieht man den Fortschritt ohne Neuladen.
    if (learn_state == 1) {
        int rest = (learn_until - millis()) / 1000;
        if (rest < 0) { rest = 0; }
        sprintf(line, "{s}Anlernen{m}<b>warte auf Tastendruck</b> (%d s){e}", rest);
        webSend(line);
    } else {
        if (learn_res == 1) { webSend("{s}Anlernen{m}neue Fernbedienung uebernommen{e}"); }
        if (learn_res == 2) { webSend("{s}Anlernen{m}war bereits bekannt — ausgewaehlt{e}"); }
        if (learn_res == 3) { webSend("{s}Anlernen{m}nichts empfangen (Abbruch/Zeit){e}"); }
        if (learn_res == 4) { webSend("{s}Anlernen{m}kein Platz frei — erst eine loeschen{e}"); }
    }
    if (rc_cnt < 1) {
        webSend("{s}Fernbedienung{m}<b>keine angelernt</b>{e}");
    } else {
        // Die beiden <span> sind KEIN Schmuck: das Bedienfeld liest sie aus, um
        // zu merken, dass sich hinter seinem Ruecken etwas geaendert hat.
        // Dieser Block wird beim Ajax-Refresh erneuert, WebPage() nicht.
        sprintf(line, "{s}Fernbedienung{m}<b><span id='gms'>%d</span></b> von <span id='gmn'>%d</span>  (Kennung %02X %02X %02X){e}",
                rc_sel + 1, rc_cnt, rc_i1[rc_sel], rc_i2[rc_sel], rc_i3[rc_sel]);
        webSend(line);
    }
    if (last_sent < 0) {
        webSend("{s}Deckenlampe{m}bereit{e}");
    } else {
        btn_name(last_sent, nm);
        int alt = (millis() - last_ms) / 1000;
        sprintf(line, "{s}Zuletzt gesendet{m}<b>%s</b>  (vor %d s){e}", nm, alt);
        webSend(line);
        sprintf(line, "{s}Rahmen{m}CC=0x%02X  Zaehler=0x%02X{e}",
                0x10 | last_sent, (tx_seq - 1) & 0xFF);
        webSend(line);
    }
    sprintf(line, "{s}Gesendet gesamt{m}%d Rahmen{e}", tx_total);
    webSend(line);
}

// ── Bedienfeld: der Fernbedienung nachempfunden ───────────────────────────
// Aufbau wie das Original: oben Ein/Aus fuer alle, in der Mitte der Ring mit
// vier Richtungen (oben/unten = Helligkeit, links/rechts = Farbtemperatur),
// unten die vier Zonen-Wippen mit je | oben und O unten.
//
// ⚠️ Prozentzeichen NUR in webSend-Literalen verwenden, NIE in einem
// sprintf-Format — dort wuerde % als Formatanweisung gelesen und das HTML
// zerstoeren. Deshalb ist der Aufbau in „sprintf fuer die Zahlen" und
// „webSend fuer das CSS" getrennt.

// Eigener Klick-Handler statt des eingebauten tcbtn(). Zwei Gruende:
//
//  1. tcbtn setzt `e.textContent = "✓"` und stellt danach den GESICHERTEN TEXT
//     wieder her. Das zerstoert Kindelemente: der weisse <span>-Punkt auf dem
//     Ring war nach dem ersten Klick fuer immer weg (gesichert wurde "", denn
//     der span traegt keinen Text). Und `e.style.color = ''` loescht die
//     Inline-Textfarbe endgueltig — gesichert wird nur der Hintergrund, nicht
//     die Farbe — danach steht Tasmotas helle Standardfarbe auf hellem Grund
//     und die Zonenzeichen sind unsichtbar. (In der Firmware behoben am
//     2026-07-28; alte Firmware im Feld hat den Fix nicht.)
//  2. tcbtn sperrt den Knopf 2,5 s (`dataset.b`). Bei einer Lampenfernbedienung
//     falsch: „heller" will man mehrmals hintereinander druecken.
//
// gmb() fasst weder Inhalt noch Farben an, sondern staucht den Knopf kurz.
// `seva(v,i)` stellt die Firmware auf der Hauptseite bereit, sobald ein Slot
// ein WebCall() hat — das haben wir.
// Der Wahlschalter braucht einen EIGENEN Handler. gmb() staucht nur kurz und
// laesst das Aussehen sonst in Ruhe — richtig fuer Momentanknoepfe, falsch
// fuer eine Zustandsanzeige: WebPage() wird NUR beim vollen Seitenaufbau
// gerendert, der periodische Ajax-Refresh erneuert allein den Sensorblock aus
// WebCall(). Ein serverseitig umgeschaltetes rc_sel erreicht die Knopffarbe
// also nie, und FB 1 blieb dauerhaft schwarz. gmrc() faerbt darum im Browser
// selbst um; beim naechsten Neuladen liefert der Server denselben Zustand.
// ⚠️ TASMOTAS STYLESHEET MUSS FUER DAS PANEL ZURUECKGESETZT WERDEN.
// Am 2026-07-29 im Browser nachgemessen, nicht geraten:
//   div    { padding:5px; box-sizing:content-box }
//   button { line-height:38.4px }
// Folgen, die beide wie Rechenfehler von uns aussahen:
//  * Der Ring war auf dem Schirm 168 px breit statt der gesetzten 158 —
//    und absolut positionierte Kinder rechnen ab der POLSTERkante. Die vier
//    Richtungspunkte sassen dadurch 5 px nach links oben verschoben
//    (gemessen: heller bei -59, dunkler bei +49 statt symmetrisch ±54).
//  * In einem 22 px hohen Knopf erzeugt line-height 38.4 px eine Textzeile,
//    die oben 12 px tief beginnt und unten 3,5 px HERAUSRAGT.
// Deshalb ein eigener Geltungsbereich #gmp mit gesetzten Grundwerten. Inline
// gesetzte Eigenschaften gewinnen weiterhin, die Sonderfaelle bleiben also.
void panel_css() {
    webSend("<style>#gmp div{box-sizing:border-box;padding:0}");
    webSend("#gmp button{box-sizing:border-box;line-height:1;margin:0;");
    webSend("display:inline-flex;align-items:center;justify-content:center}");
    webSend("</style>");
}

void panel_js() {
    webSend("<script>function gmb(e,v,i){seva(v,i);");
    webSend("e.style.transform='scale(.85)';");
    webSend("setTimeout(function(){e.style.transform='';},260);}");
    webSend("function gmrc(e,v,i){seva(v,i);");
    webSend("var b=document.querySelectorAll('[data-rc]');");
    webSend("for(var k=0;k<b.length;k++){");
    webSend("var on=b[k].getAttribute('data-rc')==String(v);");
    webSend("b[k].style.background=on?'#3a3a3a':'#fff';");
    webSend("b[k].style.color=on?'#fff':'#666';}}");
    // Loeschen und Anlernen aendern die STRUKTUR des Panels (eine Zeile faellt
    // weg, ein Knopf kommt dazu). Umfaerben wie beim Wahlschalter reicht dafuer
    // nicht, und WebPage() laeuft nur beim vollen Seitenaufbau — ohne das hier
    // stand nach dem Loeschen weiter "2", und eine neu angelernte Fernbedienung
    // wurde erst nach manuellem Neuladen sichtbar.
    // Der WebCall-Block wird dagegen bei jedem Ajax-Refresh erneuert; er traegt
    // Anzahl und Auswahl in #gmn/#gms. Weichen sie von dem ab, womit dieses
    // Panel gerendert wurde, laedt die Seite EINMAL neu.
    webSend("function gmwatch(n,s){setInterval(function(){");
    webSend("var a=document.getElementById('gmn'),b=document.getElementById('gms');");
    webSend("if(!a||!b)return;");
    webSend("if(a.textContent!=String(n)||b.textContent!=String(s))location.reload();");
    webSend("},1500);}</script>");
}

// Wahlschalter fuer die Fernbedienung. Die aktive ist dunkel hinterlegt —
// eine Zustandsanzeige, kein Momentanknopf, deshalb faerbt sich die Auswahl
// und springt nicht zurueck.
void rc_btn(int which) {
    sprintf(line, "<button data-rc='%d' title='Kennung %02X %02X %02X' onclick='gmrc(this,%d,%d)' style='",
            which, rc_i1[which-1], rc_i2[which-1], rc_i3[which-1],
            which, varIdx(ui_rc));
    webSend(line);
    webSend("border:0;cursor:pointer;font-family:inherit;font-size:11px;");
    webSend("border-radius:11px;width:56px;height:22px;margin:0 3px;padding:0;");
    if ((which - 1) == rc_sel) { webSend("background:#3a3a3a;color:#fff;"); }
    else                       { webSend("background:#fff;color:#666;"); }
    sprintf(line, "'>FB %d</button>", which);
    webSend(line);
}

// oeffnet ein <button ...> mit onclick; das Styling folgt per webSend
void btn_open(int nib) {
    sprintf(line, "<button onclick='gmb(this,%d,%d)' style='", nib, varIdx(ui_cmd));
    webSend(line);
}

// KEINE implizite String-Verkettung in TinyC ("a" "b" ist ein Parserfehler)
// -> gemeinsamer Stil als eigener webSend-Aufruf statt als #define-Praefix.
void btn_base() {
    webSend("border:0;cursor:pointer;font-family:inherit;color:#111;");
}

// Pillen-Knopf oben (Ein bzw. Aus fuer alle Lampen)
void top_btn(int nib, char sym[], char extra[]) {
    btn_open(nib);
    btn_base();
    // display:flex + auto-Raender: zwei <button> hintereinander stehen sonst
    // NEBENeinander — auf der Fernbedienung sitzen sie untereinander.
    // flex statt block, weil flex ebenfalls auf eigener Zeile steht UND den
    // Inhalt senkrecht mittet; block ueberlaesst das der Schriftmetrik.
    webSend("background:#fff;border-radius:24px;width:76px;height:44px;");
    webSend("box-shadow:0 1px 3px rgba(0,0,0,.35);font-size:20px;");
    webSend("display:flex;align-items:center;justify-content:center;margin:4px auto;");
    webSend(extra);
    sprintf(line, "'>%s</button>", sym);
    webSend(line);
}

// Richtungsknopf auf dem Ring.
// ⚠️ Position als ZAHLEN, nicht als String-Parameter: `webSend(param)` gibt
// NICHTS aus, wenn param ein char[]-Parameter ist, der ein String-Literal
// bekommen hat — tc_stream_ref() in der Firmware stieg bei Konstanten-Pool-
// Referenzen stillschweigend aus (behoben 2026-07-28, aber alte Firmware im
// Feld hat den Fix nicht). Ergebnis waren vier uebereinanderliegende Knoepfe,
// nur ein sichtbarer Punkt. `sprintf %s` kann den Fall dagegen, deshalb ist
// der Tooltip weiterhin ein String-Parameter.
// Der Marker ist ein weisser Punkt als eigenes Element, kein Textzeichen —
// Tasmotas Stylesheet beansprucht die Button-Textfarbe fuer sich. Die echte
// Fernbedienung hat ohnehin Punkte, keine Pfeile.
void ring_btn(int nib, int x, int y, char tip[]) {
    sprintf(line, "<button title='%s' onclick='gmb(this,%d,%d)' style='",
            tip, nib, varIdx(ui_cmd));
    webSend(line);
    webSend("border:0;cursor:pointer;background:transparent;padding:0;");
    webSend("position:absolute;width:44px;height:44px;border-radius:50%;");
    webSend("display:flex;align-items:center;justify-content:center;");
    sprintf(line, "left:%dpx;top:%dpx;'>", x, y);
    webSend(line);
    webSend("<span style='width:10px;height:10px;border-radius:50%;");
    webSend("background:#f2f2f2;display:block'></span></button>");
}

// halbe Wippe einer Zone (oben = ein, unten = aus); rund = 1 oben, 2 unten
void rocker(int nib, char sym[], int rund) {
    sprintf(line, "<button onclick='gmb(this,%d,%d)' style='", nib, varIdx(ui_cmd));
    webSend(line);
    webSend("border:0;cursor:pointer;font-family:inherit;color:#111;");
    webSend("background:#fff;width:40px;height:36px;font-size:16px;padding:0;");
    webSend("box-shadow:0 1px 2px rgba(0,0,0,.3);");
    if (rund == 1) { webSend("border-radius:18px 18px 4px 4px;"); }
    else           { webSend("border-radius:4px 4px 18px 18px;"); }
    sprintf(line, "'>%s</button>", sym);
    webSend(line);
}

void zone_col(int on_nib, int off_nib, int nr, int shift) {
    webSend("<div style='display:flex;flex-direction:column;align-items:center;");
    sprintf(line, "margin-top:%dpx'>", shift);
    webSend(line);
    rocker(on_nib,  "&#124;", 1);
    rocker(off_nib, "&#9675;", 2);
    sprintf(line, "<div style='font-size:11px;color:#555;margin-top:3px'>%d</div></div>", nr);
    webSend(line);
}

// ── Untermenue „Fernbedienung anlernen" ───────────────────────────────────
// Als <details> — ein natives, zugeklapptes Untermenue ohne eigenes
// JavaScript, das den Blick auf das Bedienfeld nicht stoert.
// Die Rueckmeldung steht bewusst NICHT hier, sondern in WebCall(): dieser
// Block wird nur beim vollen Seitenaufbau erzeugt und koennte einen Fortschritt
// gar nicht anzeigen.
void learn_panel() {
    webSend("<details style='max-width:246px;margin:6px auto 0;font-size:12px'>");
    webSend("<summary style='cursor:pointer;color:#666'>Fernbedienung anlernen</summary>");
    webSend("<div style='padding:6px 2px 2px;color:#666;line-height:1.4'>");
    webSend("Auf <b>Anlernen</b> klicken, dann innerhalb von 20 Sekunden eine ");
    webSend("beliebige Taste auf der neuen Fernbedienung druecken. Der Fortschritt ");
    webSend("steht oben in der Statuszeile.<br>");
    webSend("Waehrend des Anlernens wird nicht gesendet — ein nRF24 kann nicht ");
    webSend("gleichzeitig senden und empfangen.");
    webSend("</div>");

    webSend("<div style='margin:4px 0'>");
    sprintf(line, "<button onclick='gmb(this,1,%d)' style='", varIdx(ui_learn));
    webSend(line);
    webSend("border:0;cursor:pointer;font-family:inherit;font-size:12px;");
    webSend("background:#2b6;color:#fff;border-radius:4px;height:26px;");
    webSend("padding:0 10px;margin-right:4px'>Anlernen</button>");
    sprintf(line, "<button onclick='gmb(this,2,%d)' style='", varIdx(ui_learn));
    webSend(line);
    webSend("border:0;cursor:pointer;font-family:inherit;font-size:12px;");
    webSend("background:#ddd;color:#333;border-radius:4px;height:26px;");
    webSend("padding:0 10px'>Abbrechen</button>");
    webSend("</div>");

    // Bekannte Fernbedienungen mit Kennung und Loeschknopf
    int z = 0;
    while (z < rc_cnt) {
        webSend("<div style='display:flex;align-items:center;gap:6px;margin:3px 0'>");
        sprintf(line, "<span style='width:150px'>FB %d &nbsp; %02X %02X %02X</span>",
                z + 1, rc_i1[z], rc_i2[z], rc_i3[z]);
        webSend(line);
        sprintf(line, "<button onclick='gmb(this,%d,%d)' style='", 10 + z, varIdx(ui_learn));
        webSend(line);
        webSend("border:0;cursor:pointer;font-family:inherit;font-size:11px;");
        webSend("background:#e55;color:#fff;border-radius:4px;height:22px;");
        webSend("padding:0 8px'>loeschen</button></div>");
        z = z + 1;
    }
    if (rc_cnt >= RC_MAX) {
        webSend("<div style='color:#a00'>Alle Plaetze belegt — erst eine loeschen.</div>");
    }
    webSend("</details>");
}

void WebPage() {
    panel_css();
    panel_js();
    // Der frühere Ausgleich margin-left:-30px ist WEG — er kompensierte nur
    // die 5-px-Polsterungen, die panel_css jetzt an der Wurzel abstellt.
    webSend("<div id='gmp'>");
    // Gehaeuse
    webSend("<div style='max-width:246px;margin:10px auto;padding:16px 12px 14px;");
    webSend("background:linear-gradient(#fdfdfd,#eeeeef);border-radius:118px 118px 96px 96px/150px 150px 110px 110px;");
    webSend("box-shadow:0 2px 10px rgba(0,0,0,.25);text-align:center'>");

    // Welche Fernbedienung wird nachgebildet? Steht ganz oben, weil davon
    // abhaengt, WELCHE Lampen ueberhaupt zuhoeren.
    webSend("<div style='margin:0 0 6px'>");
    int z = 1;
    while (z <= rc_cnt) { rc_btn(z); z = z + 1; }
    if (rc_cnt < 1) {
        webSend("<span style='font-size:11px;color:#a00'>keine Fernbedienung angelernt</span>");
    }
    webSend("</div>");

    top_btn(0x5, "&#124;",  "");                 // alle EIN
    top_btn(0x9, "&#9675;", "");                 // alle AUS

    // Ring
    webSend("<div style='position:relative;width:158px;height:158px;margin:12px auto 10px;");
    webSend("border-radius:50%;background:radial-gradient(circle,#3a3a3a 0 34%,#1b1b1b 35% 100%);");
    webSend("box-shadow:inset 0 1px 3px rgba(255,255,255,.15)'>");
    // Mittelscheibe zuerst — sie ist nur Blende, kein Knopf, und darf die
    // Richtungsknoepfe nicht ueberdecken.
    webSend("<div style='position:absolute;left:47px;top:47px;width:64px;height:64px;");
    webSend("border-radius:50%;background:linear-gradient(#fbfbfb,#e8e8e8);");
    webSend("box-shadow:0 1px 3px rgba(0,0,0,.45)'></div>");
    // Ring 158 px, Knopf 44 px -> Mitte bei (158-44)/2 = 57
    ring_btn(0xC, 57,  3, "heller");
    ring_btn(0x4, 57, 111, "dunkler");
    ring_btn(0xE,  3, 57, "warm");
    ring_btn(0xF, 111, 57, "kalt");
    webSend("</div>");

    webSend("<div style='font-size:10px;color:#666;margin:0 0 10px'>");
    webSend("oben/unten heller &amp; dunkler &middot; links/rechts warm &amp; kalt</div>");

    // Zonen — leicht versetzt wie auf dem Original
    webSend("<div style='display:flex;justify-content:center;gap:7px;margin-top:4px'>");
    zone_col(0x8, 0xB, 1, 0);
    zone_col(0xD, 0x3, 2, 16);
    zone_col(0x7, 0xA, 3, 16);
    zone_col(0x2, 0x6, 4, 0);
    webSend("</div>");

    webSend("<div style='font-size:10px;color:#888;margin-top:10px'>2.4G RF</div>");
    webSend("</div>");          // Gehaeuse zu
    learn_panel();
    // Mit dem Stand starten, mit dem dieses Panel gebaut wurde.
    sprintf(line, "<script>gmwatch(%d,%d);</script>", rc_cnt, rc_sel + 1);
    webSend(line);
    webSend("</div>");          // #gmp zu
}

// Hex-Ziffer -> Zahl. strToInt liest nur dezimal, und die Tastencodes sind
// nun einmal Nibbles.
int hex_nib(char s[]) {
    strToUpper(s);
    if (strcmp(s, "0") == 0) { return 0x0; }
    if (strcmp(s, "1") == 0) { return 0x1; }
    if (strcmp(s, "2") == 0) { return 0x2; }
    if (strcmp(s, "3") == 0) { return 0x3; }
    if (strcmp(s, "4") == 0) { return 0x4; }
    if (strcmp(s, "5") == 0) { return 0x5; }
    if (strcmp(s, "6") == 0) { return 0x6; }
    if (strcmp(s, "7") == 0) { return 0x7; }
    if (strcmp(s, "8") == 0) { return 0x8; }
    if (strcmp(s, "9") == 0) { return 0x9; }
    if (strcmp(s, "A") == 0) { return 0xA; }
    if (strcmp(s, "B") == 0) { return 0xB; }
    if (strcmp(s, "C") == 0) { return 0xC; }
    if (strcmp(s, "D") == 0) { return 0xD; }
    if (strcmp(s, "E") == 0) { return 0xE; }
    if (strcmp(s, "F") == 0) { return 0xF; }
    return -1;
}

void Command(char cmd[]) {
    // Waehrend des Anlernens steht der Chip auf EMPFANG. Jeder Befehl, der
    // tx_setup() oder tx_frame() anfasst, wuerde ihn umkonfigurieren und den
    // Empfang stillschweigend zerstoeren — beim Testen genau so passiert:
    // LAMPONE lief durch, der Anlernvorgang lief scheinbar weiter und haette
    // nie etwas empfangen.
    if (learn_state == 1) {
        int blockiert = 0;
        if (strStartsWith(cmd, "TX"))       { blockiert = 1; }
        if (strcmp(cmd, "ONE") == 0)        { blockiert = 1; }
        if (strcmp(cmd, "TEST") == 0)       { blockiert = 1; }
        if (blockiert) {
            responseCmnd("Anlernen laeuft — erst abschliessen oder abbrechen (LAMPLEARN aus: sv-Knopf Abbrechen)");
            return;
        }
    }
    if (strStartsWith(cmd, "TX")) {
        char a1[16];
        strToken(a1, cmd, ' ', 2);
        int nib = hex_nib(a1);
        if (nib < 0) {
            responseCmnd("LAMPTX <hex>: alle=5/9  Z1=8/B  Z2=D/3  Z3=7/A  Z4=2/6  heller=C dunkler=4  warm=E kalt=F");
            return;
        }
        tx_pend = nib;
        char nm[16];
        btn_name(nib, nm);
        sprintf(line, "sende %s (CC=0x%02X b4=%02X Zaehler 0x%02X) — 90 Rahmen",
                nm, 0x10 | nib, nib_zone(nib), tx_seq);
        responseCmnd(line);
        return;
    }
    if (strcmp(cmd, "LEARN") == 0) {
        if (rc_cnt >= RC_MAX) {
            responseCmnd("kein Platz frei — erst LAMPFORGET n");
            return;
        }
        ui_learn = 1;
        responseCmnd("Anlernen: jetzt eine Taste auf der neuen Fernbedienung druecken (20 s). Status: LAMPLIST");
        return;
    }
    if (strStartsWith(cmd, "FORGET")) {
        char a1[16];
        strToken(a1, cmd, ' ', 2);
        int n = strToInt(a1);
        if (n < 1) {
            responseCmnd("LAMPFORGET <n> — n aus LAMPLIST");
            return;
        }
        rc_forget(n - 1);
        sprintf(line, "geloescht, jetzt %d Fernbedienung(en) bekannt", rc_cnt);
        responseCmnd(line);
        return;
    }
    if (strcmp(cmd, "LIST") == 0) {
        char acc[160];
        strcpy(acc, "");
        int z = 0;
        while (z < rc_cnt) {
            char one[32];
            sprintf(one, " [%d]%02X%02X%02X", z + 1, rc_i1[z], rc_i2[z], rc_i3[z]);
            strcat(acc, one);
            z = z + 1;
        }
        if (learn_state == 1) {
            sprintf(line, "ANLERNEN laeuft, noch %d s, RX_DR=%d, Adresse %d Byte |%s",
                    (learn_until - millis()) / 1000, learn_rx, rx_aw, acc);
        } else {
            sprintf(line, "%d bekannt, aktiv %d |%s", rc_cnt, rc_sel + 1, acc);
        }
        responseCmnd(line);
        return;
    }
    if (strStartsWith(cmd, "RC")) {
        char a1[16];
        strToken(a1, cmd, ' ', 2);
        int n = strToInt(a1);
        if (n >= 1) { if (n <= rc_cnt) { rc_sel = n - 1; rc_save(); } }
        if (rc_cnt < 1) {
            responseCmnd("keine Fernbedienung angelernt — LAMPLEARN");
            return;
        }
        sprintf(line, "Fernbedienung %d von %d (Kennung %02X %02X %02X)",
                rc_sel + 1, rc_cnt, rc_i1[rc_sel], rc_i2[rc_sel], rc_i3[rc_sel]);
        responseCmnd(line);
        return;
    }
    // Reiner Lesebefehl — konfiguriert NICHTS um und ist deshalb als einziger
    // auch waehrend des Anlernens erlaubt. Genau dafuer gedacht: nachsehen, ob
    // der Chip wirklich auf Empfang steht (CONFIG=03), statt es anzunehmen.
    // Rohpaket des letzten Anlernversuchs. RX_DR>0 mit unbrauchbarem Rohpaket
    // heisst: es kommt etwas an, aber die Rahmenerkennung passt nicht.
    // RX_DR=0 heisst: es kommt schlicht nichts an.
    if (strcmp(cmd, "RAW") == 0) {
        if (!learn_have) {
            sprintf(line, "kein Paket empfangen (RX_DR=%d)", learn_rx);
            responseCmnd(line);
            return;
        }
        sprintf(line, "RX_DR=%d roh: %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X %02X (gesucht: Laenge 07, dann 6 Byte + Summe)",
                learn_rx, learn_raw[0], learn_raw[1], learn_raw[2], learn_raw[3],
                learn_raw[4], learn_raw[5], learn_raw[6], learn_raw[7],
                learn_raw[8], learn_raw[9], learn_raw[10], learn_raw[11]);
        responseCmnd(line);
        return;
    }
    // BUS GEGEN BEKANNT GUTE HARDWARE PRUEFEN. Der CC1101 haengt am selben
    // SPI und antwortet auf 0xC0|0x30 (PARTNUM) und 0xC0|0x31 (VERSION -> 04
    // oder 14). Kommt das sauber, sind CLK/MOSI/MISO und die SPI-Schicht
    // bewiesen in Ordnung und der Fehler sitzt beim nRF24 (Versorgung,
    // Verdrahtung, Modul). Antwortet auch er nur FF, ist der Bus selbst tot.
    // Die nuetzlichste Diagnose des ganzen Projekts — spart enorm viel Raten.
    if (strcmp(cmd, "CC") == 0) {
        digitalWrite(CSN_PIN, 1);          // nRF24 sicher abgewaehlt
        pinMode(CC_CS_PIN, 3);
        spi[0] = 0xF0; spi[1] = 0x00;      // 0xC0 | 0x30 = PARTNUM
        digitalWrite(CC_CS_PIN, 0);
        spiTransfer(0, spi, 2, 1);
        digitalWrite(CC_CS_PIN, 1);
        int part = spi[1] & 0xFF;
        spi[0] = 0xF1; spi[1] = 0x00;      // 0xC0 | 0x31 = VERSION
        digitalWrite(CC_CS_PIN, 0);
        spiTransfer(0, spi, 2, 1);
        digitalWrite(CC_CS_PIN, 1);
        int ver = spi[1] & 0xFF;
        digitalWrite(CC_CS_PIN, 1);        // wieder abwaehlen, teilt MISO!
        sprintf(line, "CC1101: PARTNUM=%02X VERSION=%02X — Version 04 oder 14 = SPI-Bus OK, dann liegt es am nRF24. Beides FF = Bus tot.",
                part, ver);
        responseCmnd(line);
        return;
    }
    // CC1101 HART abwaehlen und sofort den nRF24 lesen. Beide teilen sich MISO;
    // liegt der CS des CC1101 nicht sicher auf HIGH, treibt er die Leitung mit
    // und der nRF24 kommt nicht durch. „Nicht anfassen" reicht NICHT.
    if (strcmp(cmd, "HI") == 0) {
        pinMode(CC_CS_PIN, 3);
        digitalWrite(CC_CS_PIN, 1);
        pinMode(CSN_PIN, 3);
        digitalWrite(CSN_PIN, 1);
        delay(5);
        int rb = digitalRead(CC_CS_PIN);
        sprintf(line, "CC-CS(GPIO%d) zurueckgelesen=%d | nRF24 danach: 00=%02X 01=%02X 02=%02X 03=%02X 05=%02X 06=%02X",
                CC_CS_PIN, rb, nrf_rd(0), nrf_rd(1), nrf_rd(2), nrf_rd(3), nrf_rd(5), nrf_rd(6));
        responseCmnd(line);
        return;
    }
    // Gegenprobe mit 1 MHz statt 4. Eine grenzwertige Leitung (Laenge, fehlende
    // Masse-Fuehrung, fehlender Stuetzkondensator) liest bei 4 MHz Muell und bei
    // 1 MHz sauber — das unterscheidet Signalqualitaet von Unterbrechung.
    if (strcmp(cmd, "SLOW") == 0) {
        spiInit(-1, -1, -1, 1);
        spiSetCS(SLOT, CSN_PIN);
        delay(5);
        int a = nrf_rd(0); int b = nrf_rd(1); int c = nrf_rd(2);
        int d = nrf_rd(3); int e = nrf_rd(5); int g = nrf_rd(6);
        spiInit(-1, -1, -1, 4);          // zurueck auf 4 MHz
        spiSetCS(SLOT, CSN_PIN);
        sprintf(line, "1 MHz: 00=%02X 01=%02X 02=%02X 03=%02X 05=%02X 06=%02X (nach Reset erwartet 08/3F/03/03/02/0E)",
                a, b, c, d, e, g);
        responseCmnd(line);
        return;
    }
    // Dieselben Register, aber ueber SPI-Slot 0 und mit CS VON HAND — genau so,
    // wie LAMPCC den CC1101 erfolgreich liest. Trennt einen Fehler in der
    // Slot-/CS-Verwaltung von einem echten Hardwareproblem: kommen hier
    // plausible Werte, ist die Verdrahtung in Ordnung und der Fehler sitzt in
    // der SPI-Slot-Nutzung des Skripts.
    if (strcmp(cmd, "MAN") == 0) {
        pinMode(CC_CS_PIN, 3);
        digitalWrite(CC_CS_PIN, 1);        // CC1101 sicher weg
        pinMode(CSN_PIN, 3);
        digitalWrite(CSN_PIN, 1);
        delay(1);
        int v[6];
        int regs[6];
        regs[0]=0; regs[1]=1; regs[2]=2; regs[3]=3; regs[4]=5; regs[5]=6;
        int i = 0;
        while (i < 6) {
            spi[0] = regs[i];              // R_REGISTER | reg
            spi[1] = 0xFF;
            digitalWrite(CSN_PIN, 0);
            spiTransfer(0, spi, 2, 1);     // Slot 0, CS nicht vom Treiber
            digitalWrite(CSN_PIN, 1);
            v[i] = spi[1] & 0xFF;
            i = i + 1;
        }
        sprintf(line, "manuell/Slot0: 00=%02X 01=%02X 02=%02X 03=%02X 05=%02X 06=%02X (nach Reset erwartet 08/3F/03/03/02/0E)",
                v[0], v[1], v[2], v[3], v[4], v[5]);
        responseCmnd(line);
        return;
    }
    // Schalten CSN und CE ueberhaupt? Ausgang setzen und zuruecklesen. Wird ein
    // Pin anderweitig belegt oder haengt er fest, ist der nRF24 nie ausgewaehlt
    // — dann liest man FF UND es wird nicht gesendet.
    if (strcmp(cmd, "PINS") == 0) {
        pinMode(CSN_PIN, 3);
        pinMode(CE_PIN, 3);
        digitalWrite(CSN_PIN, 0); digitalWrite(CE_PIN, 0);
        delay(2);
        int c0 = digitalRead(CSN_PIN);
        int e0 = digitalRead(CE_PIN);
        digitalWrite(CSN_PIN, 1); digitalWrite(CE_PIN, 1);
        delay(2);
        int c1 = digitalRead(CSN_PIN);
        int e1 = digitalRead(CE_PIN);
        digitalWrite(CSN_PIN, 1);
        digitalWrite(CE_PIN, 0);
        sprintf(line, "GPIO%d (CSN): low=%d high=%d | GPIO%d (CE): low=%d high=%d  (erwartet je 0 und 1)",
                CSN_PIN, c0, c1, CE_PIN, e0, e1);
        responseCmnd(line);
        return;
    }
    // ENTSCHEIDET, OB GPIO13 WIRKLICH AM CS DES CC1101 HAENGT.
    // LAMPCC beweist nur, dass der CC1101 antwortet, wenn wir GPIO13 toggeln —
    // nicht, dass die Leitung ihn erreicht. Liegt sein CS lose oder auf Masse,
    // ist er DAUERHAFT ausgewaehlt, treibt den geteilten MISO staendig mit, und
    // der nRF24 kommt beim Lesen nicht durch. Senden ginge weiter, denn das
    // braucht nur MOSI, SCK, CSN und CE — exakt unser Fehlerbild, und es
    // erklaert auch, warum ZWEI Module gleich versagen.
    // Test: einmal mit CS LOW lesen (soll antworten), einmal mit CS HIGH
    // (darf NICHT antworten). Gleiches Ergebnis = CS-Leitung wirkungslos.
    if (strcmp(cmd, "CCX") == 0) {
        pinMode(CC_CS_PIN, 3);
        digitalWrite(CSN_PIN, 1);          // nRF24 sicher abgewaehlt
        // a) korrekt ausgewaehlt
        digitalWrite(CC_CS_PIN, 0);
        spi[0] = 0xF1; spi[1] = 0x00;
        spiTransfer(0, spi, 2, 1);
        digitalWrite(CC_CS_PIN, 1);
        int lo = spi[1] & 0xFF;
        // b) ABGEWAEHLT — hier darf nichts Sinnvolles kommen
        delay(2);
        digitalWrite(CC_CS_PIN, 1);
        spi[0] = 0xF1; spi[1] = 0x00;
        spiTransfer(0, spi, 2, 1);
        int hi = spi[1] & 0xFF;
        sprintf(line, "CC1101 VERSION: CS=low -> %02X | CS=high -> %02X  ==> gleich = CS-Leitung wirkungslos, Chip belegt MISO dauernd",
                lo, hi);
        responseCmnd(line);
        return;
    }
    if (strcmp(cmd, "REG") == 0) {
        // Erst die Einzelregister lesen, DANN die Adresse — und die Adresse
        // sofort in Locals sichern.
        // ⚠️ nrf_rd() benutzt denselben globalen spi[]-Puffer und ueberschreibt
        // spi[0] und spi[1]. Wer die Adresse in spi[] stehen laesst und danach
        // noch nrf_rd() im selben sprintf aufruft, liest als erstes Adressbyte
        // den zuletzt gelesenen REGISTERWERT zurueck. Genau das sah hier nach
        // einer falsch programmierten Adresse aus (0x20 statt 0xAA — das war
        // RX_PW_P0=32) und haette uns auf eine falsche Faehrte geschickt.
        int cfg = nrf_rd(N_CONFIG);
        int aw  = nrf_rd(N_AW);
        int ch  = nrf_rd(N_RF_CH);
        int rf  = nrf_rd(N_RF_SET);
        int st  = nrf_rd(N_STATUS);
        int pw  = nrf_rd(0x11);
        spi[0] = N_R_REG | 0x0A;
        int z = 1;
        while (z <= 5) { spi[z] = 0; z = z + 1; }
        spiTransfer(SLOT, spi, 6, 1);
        int a1 = spi[1] & 0xFF; int a2 = spi[2] & 0xFF; int a3 = spi[3] & 0xFF;
        int a4 = spi[4] & 0xFF; int a5 = spi[5] & 0xFF;
        sprintf(line, "CONFIG=%02X AW=%02X RF_CH=%d RF_SETUP=%02X STATUS=%02X PW=%d ADDR=%02X %02X %02X %02X %02X (soll AA 5A 85 8A 55)",
                cfg, aw, ch, rf, st, pw, a1, a2, a3, a4, a5);
        responseCmnd(line);
        return;
    }
    // Diagnose des EMPFANGSPFADS ohne fremde Hilfe. Mit einer 3-Byte-Adresse
    // aus lauter 0xAA rastet der Chip staendig auf Rauschen ein — kommen hier
    // Pakete, funktionieren Antenne, MISO und der Empfangszweig, und ein
    // Fehlschlag beim Anlernen ist ein Signal-/Reichweitenproblem.
    // Kommt NICHTS, ist der Empfang selbst kaputt. (Der alte Sniffer sah so
    // rund 3 Pakete/s.)
    if (strcmp(cmd, "NOISE") == 0) {
        if (CE_PIN >= 0) { digitalWrite(CE_PIN, 0); }
        nrf_wr(N_CONFIG, 0x00);
        nrf_wr(N_EN_AA, 0x00);
        nrf_wr(0x02, 0x01);
        nrf_wr(N_AW, 0x01);              // 3 Byte Adresse
        nrf_wr(N_RF_CH, 41);
        nrf_wr(N_RF_SET, RF_1MBIT);
        nrf_wr(0x11, 32);
        spi[0] = N_W_REG | 0x0A;
        spi[1] = 0xAA; spi[2] = 0xAA; spi[3] = 0xAA;
        spiTransfer(SLOT, spi, 4, 1);
        nrf_cmd(0xE2);
        nrf_wr(N_STATUS, 0x70);
        nrf_wr(N_CONFIG, 0x03);
        delay(2);
        if (CE_PIN >= 0) { digitalWrite(CE_PIN, 1); }
        int n = 0;
        int q = 0;
        while (q < 2000) {
            if (nrf_rd(N_STATUS) & 0x40) {
                n = n + 1;
                nrf_wr(N_STATUS, 0x70);
                nrf_cmd(0xE2);
            }
            delay(1);
            q = q + 1;
        }
        tx_setup();
        sprintf(line, "%d Pakete in 2 s bei 3-Byte-Adresse (0 = Empfangspfad defekt, >0 = Empfang lebt)", n);
        responseCmnd(line);
        return;
    }
    if (strcmp(cmd, "TEST") == 0) {
        tx_setup();
        sprintf(line, "CONFIG=%02X (soll 02) AW=%02X (soll 03) RF_SETUP=%02X (soll 06) STATUS=%02X (totes Modul: FF)",
                nrf_rd(N_CONFIG), nrf_rd(N_AW), nrf_rd(N_RF_SET), nrf_rd(N_STATUS));
        responseCmnd(line);
        return;
    }
    if (strcmp(cmd, "ONE") == 0) {
        // EINEN Rahmen senden und danach den Chip fragen, ob er ihn losgeworden
        // ist. TX_DS (0x20) im STATUS = Rahmen raus. Bleibt es 0, sendet er nicht.
        tx_setup();
        int st0 = nrf_rd(N_STATUS);
        int fi0 = nrf_rd(N_FIFO);
        tx_frame(0x9, 0x77, 0, 41, 1);
        delay(3);
        sprintf(line, "vor: ST=%02X FIFO=%02X | nach: ST=%02X FIFO=%02X | CONFIG=%02X (TX_DS=0x20 erwartet)",
                st0, fi0, nrf_rd(N_STATUS), nrf_rd(N_FIFO), nrf_rd(N_CONFIG));
        responseCmnd(line);
        return;
    }
    sprintf(line, "LAMPTX <hex> | LAMPTEST | LAMPONE   (bisher %d Rahmen gesendet)", tx_total);
    responseCmnd(line);
}

int main() {
    tx_total = 0; tx_seq = 0x40; tx_pend = -1; ui_cmd = 0; ui_rc = 0;
    last_sent = -1; last_ms = 0;

    learn_state = 0; learn_res = 0; ui_learn = 0;

    // persist-Initialisierer werden verworfen, alles kommt als 0 hoch. Ein
    // rc_cnt von 0 heisst also „noch nie angelernt" — dann die beiden hier
    // gemessenen Kennungen als Startbestand setzen. Wer die Datei auf eigener
    // Hardware benutzt, loescht sie mit LAMPFORGET und lernt seine eigenen an.
    if (rc_cnt < 1) {
        rc_i1[0] = 0x5A; rc_i2[0] = 0x4B; rc_i3[0] = 0x07;   // Fernbedienung 1
        rc_i1[1] = 0x5A; rc_i2[1] = 0x43; rc_i3[1] = 0x72;   // Fernbedienung 2
        rc_cnt = 2;
        rc_sel = 0;
        rc_save();
    }
    if (rc_sel >= rc_cnt) { rc_sel = 0; }

    // ZUERST den CC1101 abwaehlen — er teilt sich MISO mit dem nRF24.
    pinMode(CC_CS_PIN, 3);
    digitalWrite(CC_CS_PIN, 1);

    spiInit(-1, -1, -1, 4);
    spiSetCS(SLOT, CSN_PIN);
    if (CE_PIN >= 0) {
        pinMode(CE_PIN, 3);          // OUTPUT (TinyC: 3, nicht 1)
        digitalWrite(CE_PIN, 0);     // im Sendebetrieb nur impulsweise high
    }
    delay(50);
    tx_setup();

    rgbLed(LED_PIN, 0x004000);  // green

    addCommand("LAMP");
    addLog("gm24g_lamp: bereit, CONFIG=0x%02X (soll 0x02)", nrf_rd(N_CONFIG));
    return 0;
}