Skip to content

max30102.tc

max30102.tc — Pulsoximeter MAX30102: Puls, SpO2 und eine MITLAUFENDE Kurve im WebUI.

Source on GitHub

// max30102.tc — Pulsoximeter MAX30102: Puls, SpO2 und eine MITLAUFENDE Kurve im WebUI.
//
// Ziel dieses Skripts ist zweierlei: der Treiber selbst, und die Probe, ob sich eine
// schnelle Messkurve ueberhaupt fluessig in der Tasmota-Oberflaeche darstellen laesst.
// Beides ist unabhaengig voneinander nutzbar.
//
// AUFBAU
//   MAX30102 an I2C, Adresse 0x57. Sonst nichts.
//   Zweiter Kanal (EKG_QUELLE): AD8232 am ESP-ADC (=1), ueber einen ADS1115 an
//   I2C 0x48 (=2), oder ein Varioport-Rekorder ueber BLE (=3, BRSP/BlueRadios).
//   Umschalten ohne die Datei anzufassen: -DEKG_QUELLE=n beim Uebersetzen.
//   Ohne angeschlossenen Verstaerker zeigt der Kanal das Rauschen des offenen
//   Eingangs — die Kette laesst sich damit pruefen, bevor die Hardware da ist.
//
// ⚠️ MUSS IN SLOT 0 LAUFEN. webOn() traegt die Adresse zwar slot-blind ein, der
// Verteiler ruft aber immer slots[0]. Aus Slot 1 waere der Endpunkt erreichbar und
// wuerde trotzdem das falsche Programm aufrufen — ohne Fehlermeldung.
//
// ⚠️ Abtasten gehoert in TaskLoop(), nicht in einen Tick-Callback: die haben ein
// Budget von 200.000 Anweisungen je Aufruf. Und NICHT in einen spawnTask-Worker —
// der laeuft auf einer eigenen VM, in der jedes Feld die Groesse 0 hat.
//
// ⚠️ NOCH NICHT FEINGESCHLIFFEN (gemu, 2026-08-07: "sieht ganz gut aus, braucht aber
// noch Feinschliff"). Offen sind mindestens: die schwankende Empfangsrate (182..278
// statt nominal 256, mit vereinzelten Synchronfehlern -- riecht nach Paketverlusten),
// die Empfindlichkeit VP_UV_SPANNE fuer andere Ableitungen, und ob die Grundlinien-
// Zeitkonstante VP_BASIS_TK bei Bewegung schnell genug nachfuehrt.
//
// ⚠️ Das ist ein Bastelaufbau zum Anschauen, KEIN Medizingeraet. Der MAX30102 ist ein
// Sensor ohne jede Zulassung, und die Zahlen unten sind zum Spielen, nicht zum
// Entscheiden.

// ---------------------------------------------------------------------------------
// Einstellungen
// ---------------------------------------------------------------------------------
#define MAX_ADDR    0x57
#define I2C_BUS     0

#define ABTASTRATE  100      // Hz, muss zu SPO2_CONFIG unten passen
// ⚠️⚠️ BEI QUELLE 3 GIBT DER VARIOPORT DEN TAKT VOR, nicht das Pulsoximeter.
// Belegt durch den direkten Vergleich am selben Signal: der eigenstaendige Prober
// (variograf_ekg.tc) schreibt JEDEN der 250 Werte/s in den Ring -- dort sah die Kurve
// perfekt aus. Diese Fassung duennte auf die 100 Hz des PPG aus, und dieselbe Person,
// dasselbe Geraet, derselbe Funk ergaben eine "sehr maessige" Kurve. Ein QRS-Komplex
// ist bei 100 Hz nur zwei bis vier Werte breit -- JEDE Auswahl daraus verformt ihn,
// egal wie clever sie ist (letzter Wert, Mittelwert, Extremwert: alle probiert).
// Die Pulswelle ist dagegen so langsam und glatt, dass ihr das Wiederholen von Werten
// optisch nichts ausmacht. Also laeuft der Ring mit 250 Hz und das PPG haelt dazwischen.
#define KURVE_N     240      // PPG-Ring: 2,4 s bei 100 Hz
#define ANZ_RATE    100      // Lesezeigertakt des PPG im Browser
#if EKG_QUELLE == 3
#define EKG_N       500      // EKG-Ring: eigener Takt, eigene Laenge (~2 s bei 250 Hz)
#endif
#define FENSTER     100      // Werte fuer die Spitze-Tal-Messung (= 1 s bei 100 Hz)

// EKG-Zusatzkanal — spaeter, jetzt schon vorgesehen:
//   0 = aus (nur MAX30102)
//   1 = AD8232 direkt am ESP-ADC        (schnell: 16.090 Lesungen/s gemessen)
//   2 = AD8232 ueber ADS1115 an I2C     (langsamer, siehe Hinweis bei ekg_lesen)
//   3 = Varioport-Rekorder ueber BLE    (BRSP/BlueRadios, 250 Hz -- siehe unten)
// ⚠️ Ein eigenes #define hier wuerde ein -DEKG_QUELLE=n von der Befehlszeile
// UEBERSCHREIBEN (die spaetere Definition gewinnt). Mit #ifndef bleibt beides moeglich:
// Vorgabe hier, Umschalten beim Uebersetzen ohne die Datei anzufassen.
#ifndef EKG_QUELLE
#define EKG_QUELLE  2
#endif
#define EKG_PIN     1        // nur bei EKG_QUELLE 1
#define ADS_ADDR    0x48     // nur bei EKG_QUELLE 2

// ── Nur bei EKG_QUELLE 3: Varioport ueber BLE ──
// Alle Werte an der Hardware ermittelt und gegen gemus iPad-App geprueft; die
// Herleitung steht ausfuehrlich in examples/variograf_ekg.tc (eigenstaendiger Prober).
//   BRSP_RX/TX sind aus Sicht des MODULS benannt: wir SCHREIBEN auf RX, LESEN von TX.
//   C02 (EKG): phys[uV] = (roh - 32768) * 5000 / 6940, Rate 256 Hz (gscan 300, Modus 0)
#define VP_MAC0 0xEC
#define VP_MAC1 0xFE
#define VP_MAC2 0x7E
#define VP_MAC3 0x10
#define VP_MAC4 0xE1
#define VP_MAC5 0xEF
#define VP_TYP     0
#define VP_SVC     "da2b84f1-6279-48de-bdc0-afbea0226079"
#define VP_TX      "18cda784-4bd3-4370-85bb-bfed91ec86af"
#define VP_RX      "bf03260c-7205-4c25-af43-93b1c299d159"
#define VP_MODE    "a87988b9-694c-479c-900e-95dfa6c00a24"
// ⚠️⚠️ DEN STARTBEFEHL NICHT ALS #define-ZEICHENKETTE. Ein Makro mit Zeichenketten-
// inhalt ueberlebt die Uebergabe durch einen char[]-PARAMETER nicht: strcpy() bekam
// nichts, herausgegangen ist ein einzelnes CR ("sende 1 Byte []"), und das Geraet hat
// brav gepiept statt zu senden. Direkt hingeschriebene Literale ("VS", "%9220") gehen
// dagegen durch -- der Unterschied ist von aussen unsichtbar und kostete eine Sitzung.
// Also zur Laufzeit bauen; dann stimmt die Kanalmaske ausserdem automatisch mit.
#define VP_KANAL   2         // C02 = EKG
// ⚠️⚠️ NICHT die volle Rate aus der .def anfordern -- die BLE-Strecke traegt sie nicht.
// Gemessen ueber 40 s: bei angeforderten 256 Hz kamen 223 Saetze/s an, also 12,8 %
// VERLUST, und zwar im eigenstaendigen Prober genauso wie hier (die Integration ist
// unschuldig). 223 Saetze/s sind 669 Byte/s -- genau die Decke, die eine ATT-MTU von 23
// bei ~30 ms Verbindungsintervall hergibt. gemus eigene iPad-App kennt das Problem und
// drosselt den BLE-Pfad ausdruecklich ("limit rate to 300 b/s on BT-LE").
// Also lieber WENIGER anfordern als verlieren: 384 -> 200 Hz = 600 Byte/s, 11 % Luft.
// Ein QRS-Komplex ist damit 16 Werte breit statt 8. Fuer eine Kabel- oder
// Classic-Verbindung kann hier wieder 300 (256 Hz) stehen.
// Gemessen (40-s-Fenster, je angeforderte Rate):
//   256 Hz -> 223/s, 12,8 % Verlust, Sync-Fehler   <- Bandbreite reicht nicht
//   200 Hz -> 185/s,  7,6 % Verlust, 0 Sync-Fehler <- gewaehlt
//   128 Hz -> 119/s,  6,9 % Verlust, 0 Sync-Fehler
// Oberhalb 200 Hz kommt ein bandbreitenbedingter Anteil MIT Desynchronisation dazu;
// darunter bleibt ein rateunabhaengiger Sockel von ~7 %, bei dem ganze Saetze SAUBER
// fehlen. 200 Hz ist damit der beste Punkt: hoechste Aufloesung ohne Stroemungsabriss.
#define VP_GSCAN   384       // 200 Hz  (.def sagt 300 = 256 Hz)
#define VP_BR      0xff      // Subtyp 24 >= 7
// Anzeige: FESTE Empfindlichkeit um eine mitlaufende Grundlinie -- wie ein echter
// EKG-Monitor, nicht selbst-einmessend.
// ⚠️ Selbst-Einmessung ist hier FALSCH: sie wird von einzelnen Bewegungsartefakten
// beherrscht. Ein Ausschlag setzt das Maximum, und das eigentliche EKG wird in die
// unteren 20 % der Hoehe gequetscht -- am Geraet als "sieht sehr schlecht aus"
// sichtbar. Bei Quelle 1/2 (AD8232, unbekannte Verstaerkung) bleibt es beim
// Einmessen; hier kennen wir die Einheit, also nehmen wir einen festen Massstab.
// ⚠️ An der HARDWARE gemessen, nicht aus dem Lehrbuch: mit gemus Elektroden lagen die
// Werte bei -1203..+3853 uV bzw. -2859..+3476. Mit +-1500 stiess die Kurve oben an
// (Anzeige klebte auf 99). +-3000 fasst die R-Zacke und laesst noch Aufloesung fuer
// P- und T-Welle. Bei anderen Elektroden/Ableitungen hier nachstellen.
// Nachgemessen: mit +-3000 lag die Spur bei 39..99 und schlug oben an (R-Zacke
// abgeschnitten). +-4000 laesst sie hineinpassen. Ein Wert, eine Zeile -- bei anderer
// Ableitung oder anderen Elektroden hier nachstellen.
#define VP_UV_SPANNE 4000
#define VP_BASIS_TK  256     // Zeitkonstante der Grundlinie in Werten (~2,5 s)
#define VP_SATZ    3                                 // 2*1 Kanal + Abschlussbyte 0x0D
#define VP_LEBEN   5000                              // XON-Takt, NICHT optional
#define EKG_FENSTER 300      // Werte, nach denen der Anzeigebereich neu bestimmt wird

// Anzeige auf einem LVGL-Display (P4). Vorerst aus — der S3 zeigt die Kurve im WebUI.
#define DISPLAY_LVGL 0

// ⭐ SIMULATION — erzeugt eine kuenstliche Pulskurve OHNE Sensor.
// Nicht nur zum Anschauen: die erzeugten Werte laufen durch dieselbe verarbeite(),
// also werden Gleichanteil-Nachfuehrung, Schlagerkennung UND die SpO2-Rechnung
// mitgeprueft. Damit laesst sich die Weboberflaeche fertig entwickeln, bevor
// ueberhaupt ein Sensor da ist — und wenn der Sensor spaeter schweigt, weiss man,
// dass es nicht an der Anzeige liegt.
#define SIMULATION  0
#define SIM_BPM     72        // Sollpuls der Simulation
#define SIM_DC      100000    // Gleichanteil beider Kanaele
#define SIM_AC_IR   2000      // Hub infrarot
#define SIM_AC_ROT  1200      // Hub rot -> R = 1200/2000 = 0.60 -> SpO2 ~97 %

// ---------------------------------------------------------------------------------
// MAX30102 — Register
// ---------------------------------------------------------------------------------
#define R_INT_ST1   0x00
#define R_FIFO_WR   0x04
#define R_OVF       0x05
#define R_FIFO_RD   0x06
#define R_FIFO_DATA 0x07
#define R_FIFO_CFG  0x08
#define R_MODE_CFG  0x09
#define R_SPO2_CFG  0x0A
#define R_LED1_PA   0x0C
#define R_LED2_PA   0x0D
#define R_PART_ID   0xFF

// ---------------------------------------------------------------------------------
// Zustand
// ---------------------------------------------------------------------------------
char fifo[192];          // 32 Messwerte a 6 Byte — mehr passt nie in einen Durchgang
char zeile[160];

int kurve[KURVE_N];      // IR-Wechselanteil, auf 0..99 skaliert (fuer die Anzeige)
int kpos;                // Schreibzeiger im Ringpuffer
int kfolge;              // zaehlt hoch, damit der Browser Neues von Altem trennen kann

int dc_ir; int dc_rot;   // Gleichanteil, gleitend nachgefuehrt
int mn_ir; int mx_ir;    // Spitze und Tal im laufenden Fenster
int mn_rot; int mx_rot;
int fz;                  // Fensterzaehler
int m3_ir1; int m3_ir2;  // die zwei vorherigen Rohwerte (Median aus drei)
int m3_rot1; int m3_rot2;
int m3_gefuellt;         // erst ab 2 gespeicherten Werten laesst sich mitteln
int spikes;              // Zaehler: entfernte Nadeln (nur deutliche)
int ovf;                 // groesster Einzelstand des Ueberlaufzaehlers
int nmax;                // wie voll der FIFO je wurde (Werte je Abholung)

int ac_ir; int ac_rot;   // zuletzt gemessener Wechselanteil (Spitze-Tal)

#if EKG_QUELLE == 3
char vp_rx[256];         // BLE-Empfangspuffer
char vp_roh[600];        // Sammelpuffer fuer angebrochene Saetze
int  vp_roh_n;
int  vp_mac[6];
int  vp_an;              // 1 = verbunden und Messbetrieb laeuft
int  vp_letzter;         // juengster Rohwert (fuer den Fall, dass eine Runde leer ist)
int  vp_rate;            // GEMESSENE Eingangsrate (Saetze/s) -- nicht geraten
int  vp_rz; int vp_rt;   // Zaehler und Zeitmarke fuer die Messung
int  vp_ausg;            // zuletzt ausgegebener Wert, in MIKROVOLT
int  vp_basis; int vp_basis_da;   // gleitende Grundlinie der Anzeige
int  ppg_y;              // ZIEL: juengster PPG-Anzeigewert vom Sensor
int  ppg_anz;            // ANGEZEIGT: gleitet zwischen den Zielen (siehe vp_satz)
int  vp_leben;           // millis() des letzten XON
int  vp_saetze; int vp_sync;
char vp_startcmd[40];
int  vp_efa[5];
int  vp_versuch;         // millis() des letzten Verbindungsversuchs
int  vp_vers_n;          // wie viele Versuche insgesamt
int  vp_calls;           // wie oft vp_lesen() lief   -- Messung statt Vermutung
int  vp_avail_max;       // groesster Wert, den bleSppAvailable() je meldete
#endif

#if EKG_QUELLE == 3
// ⭐⭐ EIGENER RING MIT EIGENEM TAKT. Zwei Signale verschiedener Rate auf EINE Zeitachse
// zu zwingen war der Entwurfsfehler: erst zerhackte das Ausduennen das EKG, dann -- nach
// dem Umstellen auf den EKG-Takt -- das Halten das PPG, und zuletzt lief der Lesezeiger
// im Browser der schwankenden Ankunftsrate davon ("Daten kommen in Schueben"). GETRENNT
// sahen beide Kurven vorher perfekt aus. Also je Kanal ein Ring, eine Folgenummer und
// ein eigener Lesezeiger. Dass die beiden minimal gegeneinander driften, ist belanglos:
// hier werden zwei Vorgaenge nebeneinander gezeigt, keine Zeitpunkte verglichen.
int ekg_kurve[EKG_N];
int ekpos; int ekfolge;
#endif
#if EKG_QUELLE == 1
int ekg_kurve[KURVE_N];  // Q1/Q2: zweiter Ring, ZEILENGLEICH mit kurve[]
#endif
#if EKG_QUELLE == 2
int ekg_kurve[KURVE_N];
#endif
#if EKG_QUELLE
int ekg_mn; int ekg_mx;  // Anzeigebereich der Selbst-Einmessung (nur Q1/Q2 genutzt)
int ekg_mn_n; int ekg_mx_n;
int ekg_fz;
#endif

int bpm;                 // Herzfrequenz
int spo2;                // Sauerstoffsaettigung in Prozent, -1 = unbekannt
int finger;              // 1 = Finger liegt auf
int letzter_schlag;      // millis() des letzten erkannten Schlags
int ueber;               // Hysterese-Merker der Schlagerkennung

int bereit;              // 1 = Sensor gefunden und eingestellt
int fehler;              // letzter Fehlercode zur Anzeige

// ---------------------------------------------------------------------------------
// SpO2-Tabelle
// ---------------------------------------------------------------------------------
// ⭐ Die Tabelle wird beim Start AUS DER FORMEL ERZEUGT, aus der auch Maxim seine
// 184 Werte erzeugt hat:
//
//     SpO2 = -45.060*R^2 + 30.354*R + 94.845        mit R = Verhaeltnis der Verhaeltnisse
//
// Das hat zwei Vorteile gegenueber dem Abtippen der Zahlenkolonne aus den C-Quellen:
// es kann sich kein Wert verschreiben, und die Herkunft steht im Klartext da statt als
// 184 unerklaerte Konstanten. Zur Laufzeit ist es trotzdem eine echte Tabelle — ein
// Nachschlagen je Messung statt Gleitkommarechnung.
//
// Index = R * 100, also 0..183. Werte ueber 100 % gibt es physikalisch nicht und
// werden gekappt; unter Index 25 (R < 0.25) ist die Kurve unbrauchbar.
#define SPO2_N 184
int spo2_tab[SPO2_N];

void spo2_tabelle_bauen() {
    int i = 0;
    while (i < SPO2_N) {
        float r = i / 100.0;
        float v = 94.845 + 30.354 * r - 45.060 * r * r;
        int w = v;
        if (w > 100) { w = 100; }
        if (w < 0)   { w = 0; }
        spo2_tab[i] = w;
        i = i + 1;
    }
}

// ---------------------------------------------------------------------------------
// Sensor einstellen
// ---------------------------------------------------------------------------------
int max_init() {
    if (!i2cSetDevice(MAX_ADDR, I2C_BUS)) { fehler = 1; return 0; }

    // ⭐ ERSTE PRUEFUNG IMMER PART_ID. Meldet der 0x15, ist der Sensor da und
    // antwortet — alles Weitere ist dann Handwerk. Ohne diese Pruefung sucht man
    // spaeter Fehler im Algorithmus, waehrend in Wahrheit ein Kabel ab ist.
    int id = i2cRead8(MAX_ADDR, R_PART_ID, I2C_BUS);
    if (id != 0x15) {
        sprintf(zeile, "MAX: PART_ID %02X statt 15 — falscher Sensor oder Verdrahtung", id);
        addLog(zeile);
        fehler = 2;
        return 0;
    }

    i2cWrite8(MAX_ADDR, R_MODE_CFG, 0x40, I2C_BUS);   // Ruecksetzen
    delay(100);

    // FIFO: keine Mittelung (die macht das Skript selbst), Ueberlauf erlaubt.
    i2cWrite8(MAX_ADDR, R_FIFO_CFG,  0x1F, I2C_BUS);
    // SpO2-Betrieb = rote UND infrarote LED
    i2cWrite8(MAX_ADDR, R_MODE_CFG,  0x03, I2C_BUS);
    // Messbereich 4096 nA, 100 Messungen/s, Pulsbreite 411 us (= volle 18 Bit)
    i2cWrite8(MAX_ADDR, R_SPO2_CFG,  0x27, I2C_BUS);
    // LED-Stroeme, je ~7 mA. Hoeher = mehr Signal, aber auch mehr Waerme und Strom.
    i2cWrite8(MAX_ADDR, R_LED1_PA,   0x24, I2C_BUS);
    i2cWrite8(MAX_ADDR, R_LED2_PA,   0x24, I2C_BUS);

    // FIFO-Zeiger auf Null, damit der erste Durchgang keinen Altbestand liest
    i2cWrite8(MAX_ADDR, R_FIFO_WR, 0, I2C_BUS);
    i2cWrite8(MAX_ADDR, R_OVF,     0, I2C_BUS);
    i2cWrite8(MAX_ADDR, R_FIFO_RD, 0, I2C_BUS);

    fehler = 0;
    addLog("MAX: MAX30102 bereit (PART_ID 15)");
    return 1;
}

// ---------------------------------------------------------------------------------
// Einen Messwert verarbeiten
// ---------------------------------------------------------------------------------
// Gleichanteil gleitend nachfuehren, Wechselanteil als Spitze-Tal ueber ein Fenster.
// Das ist bewusst einfach gehalten: fuer SpO2 zaehlt nur das VERHAELTNIS der beiden
// Wechselanteile zu ihren Gleichanteilen, nicht deren absolute Groesse.
// Ein Schritt auf der gemeinsamen Zeitachse: beide Kurven bekommen genau einen Wert.
// ⚠️ Das MUSS auch ohne Finger passieren. Sonst haengt der EKG-Kanal an der
// Finger-Erkennung des Pulsoximeters — zwei voellig unabhaengige Messungen, von denen
// die eine die andere stillstellt. Beim ersten Versuch stand der Ringschritt hinter
// dem fruehen return von verarbeite(), und ohne Finger kam GAR NICHTS mehr an, auch
// kein EKG.
void ring_schritt(int y) {
    kurve[kpos] = y;
#if EKG_QUELLE
    // ⭐ HIER, nicht in TaskLoop: so bekommen beide Ringe bei JEDEM Schritt genau
    // einen Wert und koennen nicht auseinanderlaufen. Der Browser braucht dann nur
    // EINEN Lesezeiger fuer beide Kurven — zwei Zeiger, die getrennt nachgeregelt
    // werden, driften sonst gegeneinander und die Kurven verschieben sich zusehends.
#if EKG_QUELLE == 3
    // nichts -- der EKG-Ring hat seinen eigenen Takt (siehe vp_satz)
#else
    ekg_kurve[kpos] = ekg_punkt(ekg_wert);
#endif
#endif
    kpos = kpos + 1;
    if (kpos >= KURVE_N) { kpos = 0; }
    kfolge = kfolge + 1;
}

// Rest der Auswertung, nur mit Finger sinnvoll.

// Der mittlere der drei Werte der Groesse nach.
int med3(int a, int b, int c) {
    if ((a <= b && b <= c) || (c <= b && b <= a)) { return b; }
    if ((b <= a && a <= c) || (c <= a && a <= b)) { return a; }
    return c;
}

void verarbeite(int rot, int ir) {
    // Finger da? Ohne Auflage liefert der Sensor nur Streulicht, und dann ist jede
    // Zahl unten Rauschen. ⚠️ Das ist die haeufigste Fehlerquelle beim Ausprobieren.
    if (ir < 20000) {
        finger = 0;
        bpm = 0;
        spo2 = -1;
        ring_schritt(50);      // flache Mittellinie — die Zeitachse laeuft weiter
        return;
    }
    if (finger == 0) {
        // Frisch aufgelegt: Gleichanteile sofort auf den Istwert setzen, sonst
        // braucht das gleitende Mittel Sekunden und die erste Messung ist Unsinn.
        finger = 1;
        dc_ir = ir; dc_rot = rot;
        mn_ir = ir; mx_ir = ir; mn_rot = rot; mx_rot = rot;
        m3_ir1 = ir; m3_ir2 = ir; m3_rot1 = rot; m3_rot2 = rot;
        m3_gefuellt = 0;
        fz = 0;
    }

    // ⚠️ Einzelne Ausreisser verwerfen. Bei 100 Hz kann sich die Durchblutung von
    // einem Wert zum naechsten nur um Promille aendern — ein Sprung um mehr als ein
    // Achtel des Gleichanteils ist ein verrutschter FIFO-Satz oder ein Busfehler,
    // nie ein Puls. Solche Werte werden GEHALTEN statt durchgereicht: eine einzelne
    // Zacke waere sonst das Auffaelligste im Bild, und sie verdirbt zusaetzlich
    // Min/Max und damit die SpO2-Rechnung fuer das ganze Fenster.
    // ⚠️ EINZELNE NADELN — der Median aus drei Werten, nicht eine Sprungschwelle.
    //
    // Ueber die Sprunghoehe zu gehen funktioniert hier nicht: die kleinen Kerben auf
    // der Anstiegsflanke sind nur ein Zehntel des Hubs hoch, also genau so gross wie
    // die steilste ECHTE Flanke. Jede Schwelle, die sie faengt, beschneidet auch den
    // Puls. (Erster Versuch war ausserdem an den GLEICHanteil gehaengt — 15.000 gegen
    // 2.000 Nutzsignal, damit kam jeder sichtbare Ausreisser durch.)
    //
    // Der Median von drei aufeinanderfolgenden Werten hat die Eigenschaft, die hier
    // zaehlt: EINE Nadel, egal wie hoch, kann nie der mittlere von drei Werten sein
    // und faellt vollstaendig heraus — waehrend eine monotone Flanke unveraendert
    // durchgeht, weil ihr mittlerer Wert auch der mittlere der Groesse nach ist. Eine
    // Glaettung wuerde dagegen die Pulsflanke mit abrunden.
    // Preis: ein Wert Verzoegerung (10 ms). Zwei Nadeln direkt hintereinander bleiben.
    if (m3_gefuellt < 2) {
        m3_ir2 = m3_ir1; m3_ir1 = ir;
        m3_rot2 = m3_rot1; m3_rot1 = rot;
        m3_gefuellt = m3_gefuellt + 1;
        return;
    }
    int i_neu = med3(m3_ir2, m3_ir1, ir);
    int r_neu = med3(m3_rot2, m3_rot1, rot);
    // ⚠️ Nur ECHTE Korrekturen zaehlen. Der Median weicht bei jedem bisschen Rauschen
    // vom mittleren Wert ab — als Zaehler genommen ergab das 912 "Ausreisser" in 30 s,
    // also 30 % aller Werte, und war damit als Mass wertlos. Gezaehlt wird erst, was
    // ein Viertel des Pulshubs ausmacht: das sieht man auch in der Kurve.
    int korr = i_neu - m3_ir1;  if (korr < 0) { korr = 0 - korr; }
    int schw = ac_ir >> 2;      if (schw < 100) { schw = 100; }
    if (korr > schw) { spikes = spikes + 1; }
    m3_ir2 = m3_ir1; m3_ir1 = ir;
    m3_rot2 = m3_rot1; m3_rot1 = rot;
    ir = i_neu; rot = r_neu;

    // Gleitender Gleichanteil. ⚠️ Die Zeitkonstante muss DEUTLICH LAENGER sein als
    // ein Herzschlag, sonst folgt der "Gleich"anteil der Pulswelle selbst und frisst
    // genau das Signal weg, das gemessen werden soll. 64 Werte sind bei 100 Hz rund
    // 0,64 s gegen 0,83 s Schlagdauer — vorher standen hier 16 (0,16 s), und die
    // Kurve war entsprechend flach.
    // Fuer SpO2 ist das weniger kritisch als es klingt: beide Kanaele werden gleich
    // gedaempft, und ins Ergebnis geht nur ihr VERHAELTNIS ein.
    dc_ir  = dc_ir  + (ir  - dc_ir)  / 64;
    dc_rot = dc_rot + (rot - dc_rot) / 64;

    // Spitze und Tal im laufenden Fenster
    if (ir  < mn_ir)  { mn_ir  = ir; }
    if (ir  > mx_ir)  { mx_ir  = ir; }
    if (rot < mn_rot) { mn_rot = rot; }
    if (rot > mx_rot) { mx_rot = rot; }

    // Wechselanteil fuer die Anzeige: um den Gleichanteil zentriert und auf 0..99
    // gestaucht. Der Massstab kommt aus dem zuletzt gemessenen Hub, damit die Kurve
    // unabhaengig von Fingerdruck und Hautton immer den Schirm ausfuellt.
    // ⚠️ hub ist SPITZE-TAL, die Auslenkung um den Mittelwert also nur die Haelfte
    // davon. Mit `* 45 / hub` fuellt die Kurve deshalb nur den halben Schirm (gemessen:
    // 39..77 statt 0..99). Der Faktor muss 90 sein, damit ein voller Hub die vollen
    // +-45 ergibt.
    int hub = ac_ir;
    if (hub < 200) { hub = 200; }
    int y = 50 + ((ir - dc_ir) * 90) / hub;
    if (y < 0)  { y = 0; }
    if (y > 99) { y = 99; }
    ring_schritt(y);

    // Schlagerkennung mit Hysterese: ausloesen beim Ueberschreiten von 70 % des Hubs,
    // scharfstellen erst wieder unter 40 %. Ohne die zweite Schwelle zaehlt jeder
    // Zacken doppelt. Dazu eine Sperrzeit von 300 ms = hoechstens 200 Schlaege/min.
    int schwelle_auf = dc_ir + (hub * 7) / 20;
    int schwelle_ab  = dc_ir + (hub * 2) / 20;
    if (ueber == 0) {
        if (ir > schwelle_auf) {
            ueber = 1;
            int jetzt = millis();
            int dt = jetzt - letzter_schlag;
            if (dt > 300 && dt < 3000) {
                int neu = 60000 / dt;
                // sanft nachfuehren statt springen — einzelne Fehltreffer fallen so
                // nicht als wilder Ausschlag auf
                if (bpm == 0) { bpm = neu; }
                else          { bpm = (bpm * 3 + neu) / 4; }
            }
            letzter_schlag = jetzt;
        }
    } else {
        if (ir < schwelle_ab) { ueber = 0; }
    }

    // Fenster voll -> Wechselanteile und SpO2 neu bestimmen
    fz = fz + 1;
    if (fz >= FENSTER) {
        fz = 0;
        ac_ir  = mx_ir  - mn_ir;
        ac_rot = mx_rot - mn_rot;
        mn_ir = ir; mx_ir = ir; mn_rot = rot; mx_rot = rot;

        // R = (AC_rot / DC_rot) / (AC_ir / DC_ir), mal 100 als Tabellenindex.
        // ⚠️ Ganzzahlig gerechnet: erst multiplizieren, dann teilen, sonst bleibt
        // von den Verhaeltnissen nichts uebrig.
        // ⚠️⚠️ NICHT als EINEN Bruch ausrechnen. `ac_rot * dc_ir * 100` sind bei
        // typischen Werten (1200 * 100000 * 100) ZWOELF MILLIARDEN und sprengen int32
        // um das Sechsfache — heraus kommt Unsinn, und spo2 bleibt stumm auf -1.
        // Die Simulation hat das binnen Sekunden gezeigt; am echten Sensor waeren die
        // Groessenordnungen dieselben gewesen, nur haette man dort den Sensor
        // verdaechtigt statt die Rechnung.
        //
        // Deshalb beide Verhaeltnisse EINZELN auf 1000 skalieren — je Zwischenwert
        // hoechstens 262143 * 1000 = 2,6e8, also reichlich Luft — und erst dann teilen.
        if (ac_ir > 0 && dc_rot > 0 && dc_ir > 0) {
            int zaehler = (ac_rot * 1000) / ac_ir;    // 1000 * AC_rot/AC_ir
            int nenner  = (dc_rot * 1000) / dc_ir;    // 1000 * DC_rot/DC_ir
            if (nenner > 0) {
                int idx = (zaehler * 100) / nenner;   // = 100 * R
                if (idx >= 0 && idx < SPO2_N) { spo2 = spo2_tab[idx]; }
                else                          { spo2 = -1; }
            }
        }
    }
}

// ---------------------------------------------------------------------------------
// FIFO leeren
// ---------------------------------------------------------------------------------
// ⚠️ In EINEM Zug lesen. i2cRead kann 255 Byte am Stueck, das sind 42 Messwerte —
// je Wert einzeln zu lesen waere die 6-fache Bus-Zeit fuer dasselbe Ergebnis.
int max_lesen() {
    int wr = i2cRead8(MAX_ADDR, R_FIFO_WR, I2C_BUS);
    int rd = i2cRead8(MAX_ADDR, R_FIFO_RD, I2C_BUS);
    // ⚠️ Ueberlaufzaehler mitlesen. Steht er ueber 0, hat der Sensor Werte
    // WEGGEWORFEN, weil wir zu spaet abgeholt haben — die Kurve hat dann eine echte
    // Luecke, und die sieht beim Zeichnen wie ein Sprung aus. Das ist der einzige
    // Ausreisser, der NICHT am Bus liegt, deshalb getrennt zaehlen.
    int o = i2cRead8(MAX_ADDR, R_OVF, I2C_BUS);
    if (o > ovf) { ovf = o; }

    int n = wr - rd;
    if (n < 0) { n = n + 32; }          // FIFO ist 32 Werte tief und laeuft um
    if (n > nmax) { nmax = n; }
    if (n == 0) { return 0; }
    if (n > 32) { n = 32; }

    int bytes = n * 6;
    if (!i2cRead(MAX_ADDR, R_FIFO_DATA, fifo, bytes, I2C_BUS)) { fehler = 3; return 0; }

    int i = 0;
    while (i < n) {
        int p = i * 6;
        // 18 Bit, rechtsbuendig — die oberen 6 Bit jedes 3-Byte-Blocks sind Muell
        int rot = ((fifo[p]   & 0xFF) << 16) | ((fifo[p+1] & 0xFF) << 8) | (fifo[p+2] & 0xFF);
        int ir  = ((fifo[p+3] & 0xFF) << 16) | ((fifo[p+4] & 0xFF) << 8) | (fifo[p+5] & 0xFF);
        rot = rot & 0x03FFFF;
        ir  = ir  & 0x03FFFF;
        verarbeite(rot, ir);
        i = i + 1;
    }
    return n;
}

// ---------------------------------------------------------------------------------
// Optionaler EKG-Kanal
// ---------------------------------------------------------------------------------
#if EKG_QUELLE
int ekg_wert;

#if EKG_QUELLE == 3
// Ein Satz vom Varioport: [hi][lo][0x0D], 16 Bit big-endian.
// ⚠️ ZACKENERHALTEND AUSDUENNEN. Der Rekorder liefert 250 Werte/s, diese Schleife holt
// nur 100-mal je Sekunde ab -- es bleiben also 2-3 Werte je Runde uebrig, von denen
// nur einer in den Ring darf. Einfach den LETZTEN zu nehmen verschluckt R-Zacken, und
// zu MITTELN stumpft sie ab; beides waere bei einem EKG genau der falsche Verlust,
// weil dort die Zackenhoehe der Informationstraeger ist. Genommen wird deshalb der
// Wert mit dem groessten Abstand zur Bereichsmitte.
void vp_satz(int hi, int lo) {
    int r = ((hi & 0xFF) << 8) | (lo & 0xFF);
    vp_letzter = r;
    vp_saetze = vp_saetze + 1;

    // ⭐ JEDER Wert kommt in den Ring -- keine Ausduennung, keine Warteschlange.
    // Beides war noetig, solange das PPG den Takt vorgab, und beides hat die Kurve
    // verdorben. Jetzt traegt der Geraetetakt die Zeitachse, und das PPG haelt seinen
    // letzten Wert dazwischen.
    // In MIKROVOLT umrechnen: (roh-32768)*5000/6940. Ueberlauf geprueft (164 Mio).
    ekg_wert = ((r - 32768) * 5000) / 6940;
    vp_ausg = ekg_wert;

    // (frueher wurden hier PPG-Zwischenwerte berechnet -- entfaellt, eigener Ring)
    // ⚠️ ALT: Der Ring laeuft mit dem Takt des
    // Varioports (~250/s), das Pulsoximeter liefert aber nur 100 Werte/s -- jeder Wert
    // wuerde also zweieinhalbmal wiederholt, und daraus wird im Bild eine TREPPE
    // (am Geraet als "PPG sieht extrem zackig aus" aufgefallen).
    // Halten beim Hochtakten ist dasselbe Uebel wie Wegwerfen beim Ausduennen: beide
    // erzeugen Kanten, die im Signal nicht vorkommen. Die Pulswelle ist glatt und
    // langsam, also darf dazwischen geglitten werden -- je Ringschritt die halbe
    // Reststrecke zum Ziel.
    ekg_kurve[ekpos] = ekg_punkt(ekg_wert);
    ekpos = ekpos + 1;
    if (ekpos >= EKG_N) { ekpos = 0; }
    ekfolge = ekfolge + 1;

    // ⚠️⚠️ AM EINGANGSTAKT AUSDUENNEN, NICHT AM ABHOLTAKT. Erster Versuch nahm je
    // TaskLoop-Runde einen Wert -- und BLE liefert nicht gleichmaessig, sondern
    // STOSSWEISE: alle 30-50 ms kommt ein Paket mit einem Dutzend Saetzen auf einmal.
    // Die meisten Runden blieben dadurch leer (letzter Wert gehalten = flache Strecke),
    // und in der Runde mit dem Paket wurde aus zwoelf Werten einer gepickt. Ergebnis:
    // Treppen und Spruenge statt einer Kurve -- am Geraet als "EKG sieht sehr schlecht
    // aus" sichtbar. Der GERAETETAKT ist dagegen exakt gleichmaessig, also zaehlen wir
    // EINGANGSWERTE: je 250 herein genau 100 heraus.
    // ⚠️ Die Eingangsrate NICHT annehmen. Nominal sind es 256 Hz, gemessen kamen je
    // nach Funklage 168 bis 254 -- mit einer festen 250 lief die Warteschlange leer
    // und 39 % der Kurvenpunkte waren Wiederholungen (sichtbare Treppen).
    vp_rz = vp_rz + 1;
    int jz = millis();
    if (jz - vp_rt >= 1000) {
        int gemessen = (vp_rz * 1000) / (jz - vp_rt);
        if (gemessen > 50 && gemessen < 400) { vp_rate = gemessen; }
        vp_rz = 0; vp_rt = jz;
    }
}

// Sammelpuffer zerlegen; bei fehlendem 0x0D bis zum naechsten verwerfen.
void vp_zerlegen() {
    int p = 0;
    while (vp_roh_n - p >= VP_SATZ) {
        if ((vp_roh[p + VP_SATZ - 1] & 0xFF) == 0x0D) {
            vp_satz(vp_roh[p], vp_roh[p + 1]);
            p = p + VP_SATZ;
        } else {
            vp_sync = vp_sync + 1;
            int q = p;
            while (q < vp_roh_n && (vp_roh[q] & 0xFF) != 0x0D) { q = q + 1; }
            if (q >= vp_roh_n) { p = vp_roh_n; break; }
            p = q + 1;
        }
    }
    int k = 0;
    while (p + k < vp_roh_n) { vp_roh[k] = vp_roh[p + k]; k = k + 1; }
    vp_roh_n = k;
}

// ⚠️ Rueckgabewert IMMER pruefen: ein stillschweigend gescheitertes Schreiben sieht
// aus wie ein Geraet, das nicht reagiert -- das hat schon einmal zwei Tage gekostet.
int vp_sende(char t[]) {
    char b[40];
    strcpy(b, t);
    strcat(b, "\r");
    int n = strlen(b);
    int ok = bleSppWrite(VP_RX, b, n);
    // ⚠️ LAENGE MITPROTOKOLLIEREN, nicht nur Erfolg/Misserfolg. Genau das hat den Fehler
    // aufgedeckt: der Startbefehl ging als "1 Byte []" raus, weil ein #define-Makro mit
    // Zeichenketteninhalt die Uebergabe durch den char[]-Parameter nicht ueberlebt.
    // bleSppWrite meldete dabei brav "ok" -- es hatte ja etwas geschrieben, nur eben ein
    // nacktes CR. Ohne die Laenge im Log haette man das nie gesehen.
    if (!ok || n > 10) {
        sprintf(zeile, "MAX: Varioport sendet %d Byte [%s] %s", n, b, ok ? "ok" : "FEHLER");
        addLog(zeile);
    }
    return ok;
}

// Befehl schicken und die Antwort abwarten UND verwerfen -- liegengebliebene Antworten
// wuerden nach dem Start als Messrahmen gedeutet.
int vp_frage(char t[], int ms) {
    vp_sende(t);
    int bis = millis() + ms;
    int ges = 0;
    while (millis() < bis) {
        int n = bleSppAvailable();
        if (n > 0) { if (n > 255) { n = 255; } ges = ges + bleSppRead(vp_rx, n); }
        delay(20);
    }
    return ges;   // wieviel hat das Geraet geantwortet -- 0 heisst: es hoert uns nicht
}

// EIN Verbindungsversuch samt Messbetriebsstart. Bewusst nur einer je Aufruf: sechs
// blockierende Versuche am Stueck wuerden die PPG-Schleife sekundenlang anhalten und
// damit die andere Kurve ruinieren.
// Maske: efa[c>>4] |= 1 << (15-(c&15)) mit c = Kanalnummer MINUS EINS.
void vp_startcmd_bauen() {
    int i = 0;
    while (i < 5) { vp_efa[i] = 0; i = i + 1; }
    int c = VP_KANAL - 1;
    vp_efa[c >> 4] = vp_efa[c >> 4] | (1 << (15 - (c & 15)));
    sprintf(vp_startcmd, "!W%02X%04x%04x%04x%04x%04x%04x", VP_BR,
            vp_efa[0], vp_efa[1], vp_efa[2], vp_efa[3], vp_efa[4], VP_GSCAN | 0x8000);
}

int vp_start() {
    // ⚠️ Jeden Versuch MELDEN. Ein stiller Wiederholversuch ist ein blinder Fleck:
    // man sieht nur "kein EKG" und weiss nicht, ob ueberhaupt jemand klopft.
    vp_vers_n = vp_vers_n + 1;
    if (!bleSppConnect()) {
        sprintf(zeile, "MAX: Varioport-Versuch %d -- keine Verbindung", vp_vers_n);
        addLog(zeile);
        return 0;
    }
    if (!bleSppSub(VP_TX)) { addLog("MAX: Varioport BRSP_TX nicht abonniert"); return 0; }
    char m[2];
    m[0] = 1;                                   // BrspModeData -- ohne das fliesst nichts
    // ⚠️ PRUEFEN, nicht annehmen. Ein ungeprueftes Schreiben hat schon einmal einen
    // Transportfehler in ein vermeintliches Protokollraetsel verwandelt.
    int mw = bleSppWrite(VP_MODE, m, 1);
    if (!mw) { addLog("MAX: Varioport-Datenmodus NICHT gesetzt"); }
    delay(200);
    // Reihenfolge wie in der Original-App: erst die Abfragen, dann NUR %9220, dann !W.
    // (Ein zusaetzliches %9401 -- "Befehlsmodus" -- laesst das Geraet stumm bleiben.)
    // ⚠️ Die Antwort auf VS ist die Lebendprobe: kommt hier nichts, ist jedes weitere
    // Kommando sinnlos, und der Fehler liegt vor dem Protokoll.
    int vs = vp_frage("VS", 400);
    sprintf(zeile, "MAX: Varioport VS -> %d Byte Antwort", vs);
    addLog(zeile);
    vp_frage("%00",   400);
    vp_frage("%13",   400);
    vp_frage("%08",   400);
    vp_frage("%09",   400);
    vp_frage("%9220", 200);
    vp_roh_n = 0; vp_saetze = 0; vp_sync = 0;
    vp_rate = 250; vp_rz = 0; vp_rt = millis();     // nur Diagnose
    vp_leben = millis();
    vp_startcmd_bauen();
    if (!vp_sende(vp_startcmd)) { addLog("MAX: Varioport-Startbefehl NICHT geschrieben"); return 0; }
    vp_an = 1;
    addLog("MAX: Varioport laeuft (C02 EKG, 250 Hz -> auf die PPG-Rate ausgeduennt)");
    return 1;
}

int vp_lesen() {
    vp_calls = vp_calls + 1;
    // ⚠️ WIEDERHOLT VERSUCHEN. Der Rekorder schaltet sich nach einer Minute ohne
    // Verbindung ab und wird zwischendurch ein- und ausgeschaltet; eine Quelle, die
    // sich nur beim Programmstart einmal meldet, waere fuer einen Monitor unbrauchbar.
    if (!vp_an) {
        int jetzt = millis();
        if (jetzt - vp_versuch >= 15000) { vp_versuch = jetzt; vp_start(); }
        return vp_letzter;
    }
    if (!bleSppState()) {
        addLog("MAX: Varioport-Verbindung verloren");
        vp_an = 0;
        return vp_letzter;
    }
    // ⚠️ Lebenszeichen VOR dem Lesen: %9220 hat im Geraet einen Zeitueberlauf scharf
    // gemacht: bleibt das XON aus, beendet es die Uebertragung von selbst -- ohne
    // Fehler, sie hoert einfach auf.
    int jetzt = millis();
    if (jetzt - vp_leben >= VP_LEBEN) {
        vp_leben = jetzt;
        char x[2];
        x[0] = 0x11;
        bleSppWrite(VP_RX, x, 1);
    }
    int n = bleSppAvailable();
    if (n > vp_avail_max) { vp_avail_max = n; }
    if (n > 0) {
        if (n > 255) { n = 255; }
        int got = bleSppRead(vp_rx, n);
        int i = 0;
        while (i < got) {
            if (vp_roh_n < 590) { vp_roh[vp_roh_n] = vp_rx[i]; vp_roh_n = vp_roh_n + 1; }
            i = i + 1;
        }
        vp_zerlegen();
    }
    // Der Ring wurde schon in vp_satz() weitergeschaltet -- hier ist nur noch der
    // aktuelle Wert fuer die Statusanzeige zurueckzugeben.
    return vp_ausg;
}
#endif

// ⚠️ KEIN #elif -- der TinyC-Praeprozessor kennt nur #if/#ifdef/#ifndef/#else/#endif.
// Deshalb geschachtelt statt einer Kette.
int ekg_lesen() {
#if EKG_QUELLE == 1
    // Direkt am ADC des ESP: 61 us je Lesung (gemessen auf einem S3), also rund
    // 16.000/s — das 32-fache dessen, was ein EKG braucht.
    return analogRead(EKG_PIN);
#else
#if EKG_QUELLE == 3
    return vp_lesen();
#else
    // ⚠️ ADS1115 NUR IN DAUERWANDLUNG. Der Einzelmessungs-Modus (Konfig schreiben,
    // auf das Fertig-Bit warten, lesen) schafft gemessen nur 335 Werte/s und liegt
    // damit UNTER dem Bedarf eines EKG. In Dauerwandlung sind es 1.568/s, begrenzt
    // durch den Wandler selbst (860 Messungen/s laut Datenblatt).
    // Konfigwort 0x42E3 = AIN0 gegen GND, +-2,048 V, 860 SPS, dauernd.
    char b[4];
    if (!i2cRead(ADS_ADDR, 0x00, b, 2, I2C_BUS)) { return 0; }
    int raw = ((b[0] & 0xFF) << 8) | (b[1] & 0xFF);
    if (raw > 32767) { raw = raw - 65536; }
    return raw;
#endif
#endif
}

// Auf 0..99 bringen. Anders als beim PPG gibt es hier keinen bekannten Massstab —
// was der Verstaerker liefert, haengt an Elektroden, Lage und Verstaerkung. Also
// gleitendes Selbst-Einmessen ueber ein Fenster.
// ⚠️ Untergrenze fuer die Spanne: ohne sie wird bei stillstehendem Eingang das letzte
// Bit Rauschen auf volle Schirmhoehe verstaerkt. Genau das ist der Zustand JETZT
// (kein AD8232 angeschlossen) — ohne die Grenze saehe man Vollausschlag-Grasnarbe und
// wuerde sie faelschlich fuer ein Signal halten.
#if EKG_QUELLE == 3
// Fester Massstab um eine gleitende Grundlinie. Die Grundlinie faengt die langsame
// Drift durch Elektroden und Atmung ab, die Empfindlichkeit bleibt konstant -- so
// bedeutet gleiche Hoehe im Bild immer dieselbe Spannung.
int ekg_punkt(int v) {
    if (!vp_basis_da) { vp_basis = v; vp_basis_da = 1; }
    vp_basis = vp_basis + (v - vp_basis) / VP_BASIS_TK;
    int y = 50 + ((v - vp_basis) * 49) / VP_UV_SPANNE;
    if (y < 0)  { y = 0; }
    if (y > 99) { y = 99; }
    return y;
}
#else
int ekg_punkt(int v) {
    if (v < ekg_mn_n) { ekg_mn_n = v; }
    if (v > ekg_mx_n) { ekg_mx_n = v; }
    ekg_fz = ekg_fz + 1;
    if (ekg_fz >= EKG_FENSTER) {
        ekg_mn = ekg_mn_n; ekg_mx = ekg_mx_n;
        ekg_mn_n = v; ekg_mx_n = v;
        ekg_fz = 0;
    }
    int sp = ekg_mx - ekg_mn;
    if (sp < 64) { sp = 64; }
    int y = ((v - ekg_mn) * 99) / sp;
    if (y < 0)  { y = 0; }
    if (y > 99) { y = 99; }
    return y;
}
#endif

void ekg_init() {
#if EKG_QUELLE == 3
    vp_mac[0] = VP_MAC0; vp_mac[1] = VP_MAC1; vp_mac[2] = VP_MAC2;
    vp_mac[3] = VP_MAC3; vp_mac[4] = VP_MAC4; vp_mac[5] = VP_MAC5;
    bleSppTarget(vp_mac, VP_TYP, VP_SVC);
    // Erster Versuch hier; scheitert er, wiederholt vp_lesen() ihn alle 15 s.
    // ⚠️ Beim allerersten Aufruf nach dem Start ist ein Fehlschlag NORMAL: der
    // NimBLE-Stapel ist dann meist noch nicht oben, und bleSppConnect() gibt dafuer
    // sauber 0 zurueck. ⚠️⚠️ Und immer zuerst nachsehen, ob der REKORDER laeuft --
    // er schaltet sich nach EINER MINUTE ohne Verbindung ab, was von aussen exakt
    // wie ein kaputter Verbindungsaufbau aussieht.
    vp_versuch = millis();
    if (!vp_start()) { addLog("MAX: Varioport noch nicht da -- wird alle 15 s erneut versucht"); }
#endif
#if EKG_QUELLE == 2
    char b[4];
    b[0] = 0x42; b[1] = 0xE3;
    i2cWrite(ADS_ADDR, 0x01, b, 2, I2C_BUS);
    delay(20);
    addLog("MAX: ADS1115 auf Dauerwandlung gestellt");
#endif
    ekg_wert = ekg_lesen();
    ekg_mn = ekg_wert - 32; ekg_mx = ekg_wert + 32;
    ekg_mn_n = ekg_wert; ekg_mx_n = ekg_wert;
    ekg_fz = 0;
}
#endif

// ---------------------------------------------------------------------------------
// Simulierte Kurve
// ---------------------------------------------------------------------------------
#if SIMULATION
int sim_phase;
int sim_per;        // Werte je Schlag
int sim_rnd;        // einfacher Zufallszustand fuer etwas Rauschen
int sim_last;       // millis() des zuletzt erzeugten Werts

// Form einer Fingerkuppen-Pulswelle, in Prozent des Hubs ueber die Schlagdauer:
// steiler Anstieg, Abfall, dikrote Welle (der zweite kleine Buckel vom
// Aortenklappenschluss), dann zurueck auf Null. Bewusst stueckweise linear — es
// geht um die FORM, nicht um Physiologie.
int sim_form(int p) {          // p = 0..99
    if (p < 12) { return (p * 100) / 12; }
    if (p < 45) { return 100 - ((p - 12) * 55) / 33; }
    if (p < 58) { return 45 + ((p - 45) * 18) / 13; }
    return 63 - ((p - 58) * 63) / 42;
}

void sim_schritt() {
    int p = (sim_phase * 100) / sim_per;
    if (p > 99) { p = 99; }
    int f = sim_form(p);

    // etwas Rauschen, sonst sieht die Kurve unnatuerlich sauber aus und man
    // erkennt nicht, ob die Schlagerkennung robust ist
    sim_rnd = (sim_rnd * 1103515245 + 12345) & 0x3FFFFFFF;
    int rausch = (sim_rnd % 41) - 20;          // -20..+20

    int ir  = SIM_DC + (f * SIM_AC_IR)  / 100 + rausch;
    int rot = SIM_DC + (f * SIM_AC_ROT) / 100 + rausch;
    verarbeite(rot, ir);

    sim_phase = sim_phase + 1;
    if (sim_phase >= sim_per) { sim_phase = 0; }
}
#endif

// ---------------------------------------------------------------------------------
// Laufende Arbeit
// ---------------------------------------------------------------------------------
#if EKG_QUELLE == 3
// ⚠️⚠️ VERBINDUNG BEIM STOPPEN SCHLIESSEN. TinyCStop haelt nur das PROGRAMM an -- die
// BLE-Verbindung bleibt in der Firmware offen und die Notifications laufen weiter.
// Beim Hochladen eines neuen .tcb ist das fatal: Flash-Schreiben schaltet den
// Cache ab, und ~200 Pakete/s treffen auf nicht ausfuehrbaren Code -> der
// Interrupt-Watchdog schlaegt zu (Exception 1 aus xt_highint4, mit der ELF decodiert).
// Mehrere Uploads sind genau daran gescheitert, scheinbar grundlos.
void CleanUp() {
    bleSppClose();
}
#endif

void TaskLoop() {
#if SIMULATION
    // ⚠️ NACH DER UHR erzeugen, nicht eine feste Zahl je Durchgang. Ein starres
    // "vier Werte je Runde" ergibt nur dann 100 Hz, wenn eine Runde exakt 40 ms
    // dauert — sie dauert aber 40 ms PLUS die Arbeit. Die simulierte Kurve lief
    // dadurch zu langsam und der Puls wurde als 68 statt 72 gemessen. Die
    // Schlagerkennung war dabei KORREKT: sie misst die echte Zeit zwischen den
    // Spitzen, und die Spitzen kamen wirklich zu spaet.
    int jetzt = millis();
    int dt = jetzt - sim_last;
    if (dt > 500) { dt = 500; sim_last = jetzt - 500; }   // nach einer Pause nicht aufholen
    int n = (dt * ABTASTRATE) / 1000;
    if (n > 0) {
        sim_last = sim_last + (n * 1000) / ABTASTRATE;
        int k = 0;
        while (k < n) { sim_schritt(); k = k + 1; }
    }
    bereit = 1;
    delay(20);
    return;
#endif
    if (bereit == 0) {
        bereit = max_init();
        if (bereit == 0) { delay(5000); return; }
#if EKG_QUELLE
        ekg_init();
#endif
    }
    max_lesen();
#if EKG_QUELLE
    ekg_wert = ekg_lesen();
#endif
#if EKG_QUELLE
    // ⚠️ Mit 40 ms bekaeme der EKG-Kanal nur 25 Werte/s — er wird ja einmal je Runde
    // gelesen, waehrend der MAX30102 seine vier Werte gepuffert nachliefert. 10 ms
    // ergeben 100/s und damit denselben Takt wie das PPG. Der FIFO haelt dabei nur
    // noch ein bis zwei Werte, was reichlich Reserve laesst.
    // Fuer ein ECHTES EKG sind 100 Hz die Untergrenze: der QRS-Komplex ist rund 80 ms
    // breit, also acht Werte. Zum Anschauen reicht es, zum Vermessen von Zacken nicht
    // — dafuer muesste die Schleife auf 4 ms und der Ring entsprechend groesser.
    delay(10);
#else
    // Bei 100 Hz und 32 Werten FIFO-Tiefe darf man bis 320 ms schlafen. 40 ms lassen
    // reichlich Luft und halten die Kurve im Browser fluessig.
    delay(40);
#endif
}

// ---------------------------------------------------------------------------------
// Datenendpunkt fuer die Kurve
// ---------------------------------------------------------------------------------
// Liefert den Ringpuffer als kompakte Zeichenkette. Bewusst KEIN JSON: 240 Werte als
// JSON-Feld sind rund 1,2 KB je Abruf und fuenfmal je Sekunde — als zweistellige
// Zeichen sind es 480 Byte. Der Browser zerlegt sie mit einem Zweizeiler.
char blk[128];
char argb[16];

// Liefert NUR die Werte, die seit der mitgeschickten Folgenummer dazugekommen sind:
//   GET /pulse?seit=<zuletzt gesehene Folgenummer>
// Kopf: folge,anzahl,puls,spo2,finger|<anzahl Werte zu je 2 Zeichen>
//
// ⚠️ Frueher ging bei JEDEM Abruf der ganze Ringpuffer raus und der Browser zeichnete
// erst beim Eintreffen neu. Damit war die Bildrate gleich der Abrufrate (5/s) UND von
// der Netzlaufzeit abhaengig — die Kurve ruckelte sichtbar. Jetzt kommt nur der
// Zuwachs, und gezeichnet wird unabhaengig davon (siehe WebPage).
void WebOn() {
    int h = webHandler();
    if (h != 1) { return; }

    int seit = 0;
    if (webArg("seit", argb) > 0) { seit = strToInt(argb); }
    int neu = kfolge - seit;
    if (neu < 0)        { neu = KURVE_N; }   // Zaehler lief um oder erster Abruf
    if (neu > KURVE_N)  { neu = KURVE_N; }   // laenger weg als der Puffer reicht

#if EKG_QUELLE == 3
    // ⚠️ ZWEI Folgenummern: die Kanaele laufen mit verschiedenen Takten, also kann der
    // Browser sie nicht mit einem Zeiger abfahren. Kopf:
    //   ppgfolge,ppgneu,puls,spo2,finger,ekgfolge,ekgneu,ekgrate | ppg | ekg
    int eseit = 0;
    if (webArg("eseit", argb) > 0) { eseit = strToInt(argb); }
    int eneu = ekfolge - eseit;
    if (eneu < 0)     { eneu = EKG_N; }
    if (eneu > EKG_N) { eneu = EKG_N; }
    sprintf(zeile, "%d,%d,%d,%d,%d,%d,%d,%d|", kfolge, neu, bpm, spo2, finger,
            ekfolge, eneu, vp_rate);
#else
    sprintf(zeile, "%d,%d,%d,%d,%d|", kfolge, neu, bpm, spo2, finger);
#endif
    webSend(zeile);

    // die letzten `neu` Werte, aeltester zuerst
    int p = kpos - neu;
    if (p < 0) { p = p + KURVE_N; }
    int i = 0;
    while (i < neu) {
        int o = 0;
        blk[0] = 0;
        while (o < 48 && i < neu) {
            sprintf(zeile, "%02d", kurve[p]);
            strcat(blk, zeile);
            p = p + 1;
            if (p >= KURVE_N) { p = 0; }
            o = o + 1;
            i = i + 1;
        }
        webSend(blk);
    }

#if EKG_QUELLE == 3
    // Zweiter Abschnitt: EIGENER Ring, EIGENE Anzahl.
    webSend("|");
    p = ekpos - eneu;
    if (p < 0) { p = p + EKG_N; }
    i = 0;
    while (i < eneu) {
        int o3 = 0;
        blk[0] = 0;
        while (o3 < 48 && i < eneu) {
            sprintf(zeile, "%02d", ekg_kurve[p]);
            strcat(blk, zeile);
            p = p + 1;
            if (p >= EKG_N) { p = 0; }
            o3 = o3 + 1; i = i + 1;
        }
        webSend(blk);
    }
#endif
// ⚠️ Quelle 1 UND 2 -- beide teilen sich die Zeitachse mit dem PPG und liefern ihre
// Spur aus demselben Ring. Hier stand versehentlich nur "== 1", wodurch der
// ADS1115-Weg (Quelle 2) seine EKG-Spur verloren haette. Genau der Pfad, den Rolf
// benutzt, und er waere still ausgefallen: der Browser haette zwei Abschnitte
// erwartet und nur einen bekommen.
#if EKG_QUELLE == 1 || EKG_QUELLE == 2
    webSend("|");
    p = kpos - neu;
    if (p < 0) { p = p + KURVE_N; }
    i = 0;
    while (i < neu) {
        int o2 = 0;
        blk[0] = 0;
        while (o2 < 48 && i < neu) {
            sprintf(zeile, "%02d", ekg_kurve[p]);
            strcat(blk, zeile);
            p = p + 1;
            if (p >= KURVE_N) { p = 0; }
            o2 = o2 + 1;
            i = i + 1;
        }
        webSend(blk);
    }
#endif
}

// ---------------------------------------------------------------------------------
// Anzeige auf der Hauptseite
// ---------------------------------------------------------------------------------
// ⚠️ WebPage() wird NUR beim vollstaendigen Laden der Seite gebaut, nicht bei der
// Ajax-Aktualisierung. Deshalb steht hier das Geruest samt Zeichenprogramm, und die
// Kurve holt sich ihre Werte selbst vom Endpunkt — unabhaengig von Tasmotas Takt.
void WebPage() {
    // ⚠️ ZEICHNEN UND ABHOLEN SIND GETRENNT. Wer erst beim Eintreffen der Daten
    // zeichnet, hat die Abrufrate als Bildrate — bei 5 Abrufen/s sichtbar ruckelig,
    // und jede Schwankung der Netzlaufzeit schlaegt als Zucken durch. Deshalb:
    //   * abholen alle 250 ms, nur den ZUWACHS, in einen Puffer im Browser
    //   * zeichnen mit requestAnimationFrame (~60 Bilder/s) aus diesem Puffer,
    //     mit einem Lesezeiger, der gleichmaessig nach der Uhr weiterlaeuft
    // Dasselbe Prinzip wie chartjs-plugin-streaming in core2_energy.tc, nur ohne
    // die 4 Bibliotheken vom CDN — bei 100 Hz kann man ohnehin nicht einen Punkt
    // je Abruf schicken.
    webSend("<div style='margin:8px 0'><canvas id='pk' width='480' height='180' ");
    webSend("style='width:100%;height:190px;background:#111;border-radius:4px'></canvas>");
    webSend("<div id='pv' style='text-align:center;font-size:0.9em;padding:4px'>--</div></div>");
    webSend("<script>(function(){var c=document.getElementById('pk');if(!c)return;");
    // ⚠️ N und R muessen zum Ring passen -- bei Quelle 3 laeuft er mit 250 Hz und
    // 500 Punkten, sonst mit 100 Hz und 240. Fest hineingeschriebene Werte waeren
    // beim Umschalten der Quelle still falsch.
#if EKG_QUELLE == 3
    // ⚠️ ZWEI LESEZEIGER, und der des EKG folgt der GEMELDETEN Rate. Ein fest
    // eingestelltes 250 lief der tatsaechlichen Ankunftsrate (gemessen 182..278) davon:
    // der Zeiger rannte in den Anschlag, blieb stehen und sprang beim naechsten Paket
    // weiter -- am Geraet als "Daten kommen in Schueben" sichtbar.
    sprintf(zeile, "var x=c.getContext('2d'),b=[],g=[],sq=0,eq=0,rp=0,ep=0,tl=0,W=480,H=180,N=%d,EN=%d,R=%d,ER=%d;",
            KURVE_N, EKG_N, ANZ_RATE, 250);
    webSend(zeile);
    webSend("function pull(){fetch('/pulse?seit='+sq+'&eseit='+eq).then(r=>r.text()).then(t=>{");
    webSend("var p=t.split('|');if(p.length<3)return;var h=p[0].split(',');sq=+h[0];eq=+h[5];");
    webSend("if(+h[7]>50)ER=+h[7];");
    webSend("var s=p[1],k=s.length>>1;for(var i=0;i<k;i++)b.push(+s.substr(i*2,2));");
    webSend("var s2=p[2],k2=s2.length>>1;for(var j=0;j<k2;j++)g.push(+s2.substr(j*2,2));");
    sprintf(zeile, "if(b.length>%d){var d=b.length-%d;b.splice(0,d);rp-=d;}if(rp<0)rp=0;", KURVE_N*3, KURVE_N*3);
    webSend(zeile);
    sprintf(zeile, "if(g.length>%d){var e=g.length-%d;g.splice(0,e);ep-=e;}if(ep<0)ep=0;", EKG_N*3, EKG_N*3);
    webSend(zeile);
    webSend("var f=h[4]=='1';document.getElementById('pv').textContent=f?");
    webSend("('Puls '+h[2]+' bpm   SpO2 '+(h[3]>0?h[3]+' %':'--')+'   EKG '+ER+'/s'):'kein Finger';");
    webSend("}).catch(e=>{}).finally(()=>setTimeout(pull,200));}");
    webSend("function zeig(a,pz,rate,co,y0,hh,nn,dt){");
    webSend("var lag=a.length-pz,e=(lag-nn)/nn;if(e>1)e=1;if(e<-1)e=-1;");
    webSend("pz+=dt*rate*(1+0.02*e)/1000;if(pz>a.length)pz=a.length;");
    webSend("var fi=Math.floor(pz),fr=pz-fi,st=fi-nn;if(st<0){st=0;fr=0;}");
    webSend("var n=Math.min(nn,a.length-st);");
    webSend("if(n>1){x.strokeStyle=co;x.lineWidth=2;x.beginPath();");
    webSend("for(var i=0;i<n;i++){var v=a[st+i];if(v===undefined)break;");
    webSend("var px=(i-fr)*W/nn,py=y0+hh-1-v*(hh-2)/99;");
    webSend("if(i==0)x.moveTo(px,py);else x.lineTo(px,py);}x.stroke();}return pz;}");
    webSend("function draw(now){if(!tl)tl=now;var dt=now-tl;tl=now;if(dt>200)dt=200;");
    webSend("x.fillStyle='#111';x.fillRect(0,0,W,H);var LH=H/2;");
    webSend("rp=zeig(b,rp,R,'#3ad13a',0,LH,N,dt);");
    webSend("ep=zeig(g,ep,ER,'#e08030',LH,LH,EN,dt);");
    webSend("x.strokeStyle='#333';x.lineWidth=1;x.beginPath();x.moveTo(0,LH);x.lineTo(W,LH);x.stroke();");
    webSend("x.fillStyle='#666';x.font='11px sans-serif';x.fillText('PPG',4,12);x.fillText('EKG',4,LH+12);");
    webSend("requestAnimationFrame(draw);}");
#else
    sprintf(zeile, "var x=c.getContext('2d'),b=[],g=[],sq=0,rp=0,tl=0,W=480,H=180,N=%d,R=%d;",
            KURVE_N, ANZ_RATE);
    webSend(zeile);
    // --- abholen ---
    webSend("function pull(){fetch('/pulse?seit='+sq).then(r=>r.text()).then(t=>{");
    webSend("var p=t.split('|');if(p.length<2)return;var h=p[0].split(',');sq=+h[0];");
    webSend("var s=p[1],k=s.length>>1;for(var i=0;i<k;i++)b.push(+s.substr(i*2,2));");
    // ⚠️ Der zweite Abschnitt MUSS gleich lang bleiben wie der erste, sonst laufen die
    // Kurven auseinander. Deshalb wird auch gemeinsam gekuerzt (unten), nie einzeln.
    webSend("if(p.length>2){var s2=p[2];for(var j=0;j<k;j++)g.push(+s2.substr(j*2,2));}");
    // Vorrat begrenzen und den Lesezeiger mitschieben
    sprintf(zeile, "if(b.length>%d){var d=b.length-%d;b.splice(0,d);g.splice(0,d);rp-=d;}",
            KURVE_N * 3, KURVE_N * 3);
    webSend(zeile);
    webSend("if(rp<0)rp=0;");
    webSend("var f=h[4]=='1';document.getElementById('pv').textContent=f?");
    webSend("('Puls '+h[2]+' bpm   SpO2 '+(h[3]>0?h[3]+' %':'--')):'kein Finger';");
    // ⚠️⚠️ IMMER NUR EIN ABRUF UNTERWEGS. Mit setInterval(pull,250) fragt der Browser
    // unabhaengig davon, ob die vorige Antwort schon da ist. Antwortet das Geraet
    // einmal langsam — und ein ESP mit 16 Socket-Plaetzen tut das unter Last —, sind
    // zwei Abrufe mit DERSELBEN seit-Nummer gleichzeitig offen, und beide haengen
    // ihren Abschnitt an: der Puffer bekommt dieselbe Zeit zweimal und die Kurve
    // springt zurueck. Deshalb wird der naechste Abruf erst NACH dem vorigen
    // angestossen. Nebeneffekt: hoechstens eine Verbindung statt beliebig vieler.
    webSend("}).catch(e=>{}).finally(()=>setTimeout(pull,250));}");
    // --- zeichnen ---
    // ⚠️⚠️ MIT DEM NACHKOMMAANTEIL ZEICHNEN. Ein Abtastwert ist hier 2 px breit
    // (480 px auf 240 Punkte), und bei 100 Werten/s gegen ~60 Bilder/s faellt nicht
    // in jedes Bild ein neuer Wert. Wer bei Math.floor(rp) anfaengt, laesst die Kurve
    // abwechselnd stehen und um 2 px springen — sichtbares Zittern, das man erst
    // bemerkt, wenn alles andere ruhig laeuft. Der Bruchteil verschiebt die Kurve
    // stattdessen stufenlos.
    webSend("function draw(now){if(!tl)tl=now;var dt=now-tl;tl=now;if(dt>200)dt=200;");
    // Lesezeiger nach der Uhr. Nachregeln nur SANFT (max. 2 %) — eine Korrektur um
    // 15 % sieht man als Tempowechsel, und die Regelung faengt an zu pendeln.
    webSend("var lag=b.length-rp,e=(lag-N)/N;if(e>1)e=1;if(e<-1)e=-1;");
    webSend("rp+=dt*R*(1+0.02*e)/1000;if(rp>b.length)rp=b.length;");
    webSend("var fi=Math.floor(rp),fr=rp-fi;");
    webSend("x.fillStyle='#111';x.fillRect(0,0,W,H);");
    webSend("var st=fi-N;if(st<0){st=0;fr=0;}var n=Math.min(N,b.length-st);");
    // Eine Zeichenroutine, zweimal aufgerufen — je Kanal ein eigenes Band. Uebereinander
    // gelegt waeren die beiden Kurven bei Rauschen auf dem EKG-Kanal nicht zu trennen.
    webSend("var LH=g.length?H/2:H;");
    webSend("function tr(a,co,y0,hh){if(n<2)return;x.strokeStyle=co;x.lineWidth=2;");
    webSend("x.beginPath();for(var i=0;i<n;i++){var v=a[st+i];if(v===undefined)break;");
    webSend("var px=(i-fr)*W/N,py=y0+hh-1-v*(hh-2)/99;");
    webSend("if(i==0)x.moveTo(px,py);else x.lineTo(px,py);}x.stroke();}");
    webSend("tr(b,'#3ad13a',0,LH);");
    webSend("if(g.length){x.strokeStyle='#333';x.lineWidth=1;x.beginPath();");
    webSend("x.moveTo(0,LH);x.lineTo(W,LH);x.stroke();tr(g,'#e08030',LH,LH);");
    webSend("x.fillStyle='#666';x.font='11px sans-serif';");
    webSend("x.fillText('PPG',4,12);x.fillText('EKG',4,LH+12);}");
    webSend("requestAnimationFrame(draw);}");
#endif
    webSend("pull();requestAnimationFrame(draw);})();</script>");
}

void WebCall() {
    if (bereit == 0) {
        sprintf(zeile, "{s}MAX30102{m}<span style='color:#e0a030'>nicht gefunden (Fehler %d)</span>{e}", fehler);
        webSend(zeile);
        return;
    }
    if (finger == 0) {
        webSend("{s}MAX30102{m}<span style='color:#e0a030'>kein Finger aufgelegt</span>{e}");
        return;
    }
    sprintf(zeile, "{s}Puls{m}%d bpm{e}", bpm);
    webSend(zeile);
    if (spo2 > 0) {
        sprintf(zeile, "{s}SpO2{m}%d %%{e}", spo2);
        webSend(zeile);
    } else {
        webSend("{s}SpO2{m}--{e}");
    }
}

// ---------------------------------------------------------------------------------
// Konsole
// ---------------------------------------------------------------------------------
void Command(char cmd[]) {
    if (strcmp(cmd, "ROH") == 0) {
        // Rohwerte zum Nachsehen, wenn die Zahlen unplausibel aussehen
        sprintf(zeile, "dc_ir=%d ac_ir=%d dc_rot=%d ac_rot=%d finger=%d",
                dc_ir, ac_ir, dc_rot, ac_rot, finger);
        responseCmnd(zeile);
    } else {
        if (strcmp(cmd, "INIT") == 0) {
            bereit = 0;
            responseCmnd("Sensor wird neu eingestellt");
        } else {
            #if EKG_QUELLE == 3
            sprintf(zeile, "puls=%d spo2=%d finger=%d | EKG an=%d saetze=%d rate=%d/s sync=%d uV=%d puffer_max=%d",
                    bpm, spo2, finger, vp_an, vp_saetze, vp_rate, vp_sync, vp_ausg, vp_avail_max);
#else
            sprintf(zeile, "bereit=%d fehler=%d puls=%d spo2=%d spikes=%d ovf=%d nmax=%d",
                    bereit, fehler, bpm, spo2, spikes, ovf, nmax);
#endif
            responseCmnd(zeile);
        }
    }
}

int main() {
    spo2_tabelle_bauen();
    spo2 = -1;
#if SIMULATION
    sim_per = (ABTASTRATE * 60) / SIM_BPM;   // Werte je Schlag
    sim_rnd = 12345;
    sim_last = millis();
    addLog("MAX: SIMULATION aktiv — kein Sensor noetig");
#endif
    addCommand("PULS");     // PULS / PULSROH / PULSINIT
    webOn(1, "/pulse");
    addLog("MAX: bereit — Kurve auf der Hauptseite, Daten unter /pulse");
    return 0;
}