Skip to content

usb_variolab_tcp.tc

usb_variolab_tcp.tc — bridges a TCP port to a device, over USB host OR pins.

Source on GitHub

// @name: VarioLab over TCP
// usb_variolab_tcp.tc — bridges a TCP port to a device, over USB host OR pins.
//
// WHY: the VarioLab has its FTDI soldered in, with no way to reach the UART
// before it. The only way in is for the ESP32 to BE a USB host. uVariotest then
// opens the device as if it were a local serial port: it connects to <ip>:2000
// and speaks raw bytes — no framing, no handshake.
//
//     uVariotest26  --TCP 2000-->  ESP32-S3  --USB host-->  FT232R --> VarioLab
//     uVariotest26  --TCP 2000-->  ESP32     --RX/TX pins->  VarioLab (TTL)
//
// TWO SOURCES, one bridge. The USB-host path needs an S3 (or S2) built with
// USE_TINYC_USBSERIAL and the device on the OTG socket; the pin path works on
// any ESP32 and reaches a device whose UART is on the header. Which one is
// live is a RUNTIME choice and survives a reboot:
//
//     TCUS                 what is set, and what is going through
//     TCUS usb             USB host (the OTG socket)
//     TCUS ser 16 17       serial on RX=16 TX=17, at BAUD
//     TCUS ser 16 17 115200    ... with another baud rate
//
// ⚠️ The pins are given as RX first, TX second — the order in which
// serialBegin() takes them, and the order the Tasmota web page prints. Naming
// them the other way round in a dialog and this way in the code is exactly how
// a wired-up device stays silent for an afternoon.
//
// ⚠️ For the USB path the host must supply VBUS. If nothing enumerates,
// measure that first.
//
// The pump runs in TaskLoop() — its OWN FreeRTOS task, so 23 kB/s never fights
// Tasmota's main loop for time.

// ⚠️ The look — panel, language switch, Save button, grey footer — is the
// SML family's, and it comes from their file rather than being copied: a
// second copy of that CSS would drift away from the original within a month.
// It also brings the "power saving off" switch, which matters here more than
// there: a bridge that dozes between loop passes adds latency to every byte.
#include "ui_common.tc"

#define PORT   2000        // what uVariotest expects by default
#define BAUD   230400      // VarioLab, 8N1

int b[1024];               // one int32 per byte, like the SPP family.
                           // 1024 and not 256: at 23 kB/s that is 90 round
                           // trips per second instead of 360, and the ring
                           // is drained in one go after any hiccup.
int offen = 0;
int horcht = 0;            // 1 once tcpServer() really came up
int naechster_versuch = 0; // millis() of the next attempt

// ── Which source, and how it is wired ───────────────────────────────────
//
// SET ON THE DEVICE, and it stays set:
//     TCUS usb              USB host (the OTG socket)
//     TCUS ser 16 17        serial on RX=16 TX=17
//     TCUS ser 16 17 115200 ... and a different baud rate
//
// ⚠️ `persist`: whoever wires a device to pins 16/17 does not want to say so
// again after every power cut. The values survive a reboot in the .pvs file --
// no recompiling, no upload, and a device that is already in its housing can
// be rewired over the console.
// ⚠️ Adding or removing a persist variable RESETS ALL of them to the values
// declared here — that is how the format works, not a bug. So: if the bridge
// suddenly talks USB again after an update, set the pins once more.
#define Q_USB  0
#define Q_SER  1
persist int quelle  = Q_USB;
persist int p_rx    = -1;      // GPIO for receive, -1 = not set
persist int p_tx    = -1;      // GPIO for transmit
persist int p_baud  = BAUD;

int hat_usb = 0;           // 1 when this firmware really has the USB host

// ── Baud rate as a pulldown ─────────────────────────────────────────────
// ⚠️ A LIST, NOT A FREE NUMBER FIELD. A bridge that runs at 230399 baud looks
// exactly like one with a loose wire: garbage in, garbage out, and nothing
// says which of the two it is. The pulldown cannot be typed wrong.
// ⚠️ `TCUS ser <rx> <tx> <baud>` still takes ANY number -- the console is for
// the odd case (a device at 31250 for MIDI, say), the menu for the usual ones.
// Then the menu shows the nearest entry and the real value stays in p_baud.
#define N_BAUD 8
int baudliste[N_BAUD];
int baudwahl = 5;          // index into baudliste, 5 = 230400

// ── Shadow copies, so a change in the menu is noticed ───────────────────
// ⚠️ The widgets write STRAIGHT into the persist variables -- there is no
// callback that says "the user changed something". Comparing against a shadow
// each round is the whole mechanism: without it the page would show pins 16/17
// while the bridge still ran on the old ones, and the status row would agree
// with the page and be wrong.
// ── Edit buffers ───────────────────────────────────────────────────────
// ⚠️ THE WIDGETS DO NOT WRITE INTO THE LIVE SETTINGS. They write here, and
// only Save copies them across. Same reason as in the SML panel: a pin
// changed mid-edit would reopen the port on every keystroke of a setting the
// user has not finished choosing -- I watched it do exactly that three times
// in a row while testing the immediate version.
int e_quelle;
int e_rx;
int e_tx;
int e_wahl;
byte w_buf[220];           // one HTML block at a time, like sml_buf
int sh = -1;               // serial handle from serialBegin(), -1 = closed
int s_rx = 0;              // own byte counters — usbInfo() only knows USB
int s_tx = 0;

// ── For the status rows on Tasmota's main page ──────────────────────────
int letzter_verkehr = 0;   // millis() when bytes last moved either way
int w_rx = 0;              // counter readings at the last WebCall,
int w_tx = 0;              // so the rate is the CHANGE since then
int w_ms = 0;
int w_ein = 0;             // bytes per second, device -> PC
int w_aus = 0;             // bytes per second, PC -> device

int main() {
    addCommand("TCUS");
    // ⚠️⚠️ NO USB HOST IN THIS FIRMWARE? THEN PINS, AND SAY SO. usbInit()
    // answers 1 when the host stack came up. On a build WITHOUT
    // USE_TINYC_USBSERIAL — and on every chip that is not an S3/S2 — the whole
    // family is compiled out and every one of its syscalls returns 0. Without
    // this check the bridge would sit there forever waiting for a device to
    // enumerate on a host that does not exist, with a status page saying
    // "kein Gerät an der OTG-Buchse" — true, and hiding the real reason.
    // `hat_usb` is what TCUS checks before it lets anyone switch back.
    hat_usb = 0;
    if (usbInit() == 1) { hat_usb = 1; }
    if (hat_usb == 0 && quelle == Q_USB) {
        quelle = Q_SER;
        addLog("no USB host in this firmware — bridge runs on pins only");
    }
    // ⚠️⚠️ DO NOT call tcpServer() here and be done with it. At boot the
    // script starts BEFORE the Wi-Fi link is up, and then tcpServer() returns
    // -2 ("no net") and starts nothing at all. Nothing looks broken
    // afterwards: the VM keeps running, `TinyC` reports Error "OK", the USB
    // side opens, TCUS says "usb state 3, open 1" -- and port 2000 is simply
    // refused, forever, because nobody ever tries again.
    //
    // Found on 2026-09-13 on a device with 7.5 h uptime: Wi-Fi Downtime 4 s,
    // which is exactly the window main() ran in. Restarting the script by
    // hand fixed it instantly, which is the giveaway. The retry below is in
    // TaskLoop, so the bridge also comes back after the router reboots.
    horcht = 0;
    naechster_versuch = 0;

    baudliste[0] = 9600;   baudliste[1] = 19200;  baudliste[2] = 38400;
    baudliste[3] = 57600;  baudliste[4] = 115200; baudliste[5] = 230400;
    baudliste[6] = 460800; baudliste[7] = 921600;
    // The stored rate decides which entry the menu shows -- not the other way
    // round, so a value set over the console is not silently overwritten.
    baudwahl = baud_index(p_baud);

    // ⚠️ WITHOUT webPageLabel THERE IS NO BUTTON. WebUI() alone renders a page
    // at /tc_ui, but nothing on the main page links to it -- the menu exists
    // and is unreachable, which is the kind of bug one looks for in the HTML.
    webPageLabel(0, "Br&uuml;cke");

    puffer_laden();
    return 0;
}

// Which entry of baudliste comes closest to `wert`? Used when the rate was set
// over the console, so the menu shows something true instead of jumping the
// bridge to a different speed the moment the page is opened.
int baud_index(int wert) {
    int i;
    for (i = 0; i < N_BAUD; i++) {
        if (baudliste[i] == wert) { return i; }
    }
    return 5;              // 230400 — the VarioLab
}

// Load the edit buffers from what is actually in effect.
void puffer_laden() {
    e_quelle = quelle;
    e_rx = p_rx;
    e_tx = p_tx;
    e_wahl = baud_index(p_baud);
}

// ─── Once a second: the shared bookkeeping, then Save ───────────────────
//
// ⚠️ uiApplyBegin() FIRST, before anything reads ui_applying/ui_reloading --
// it is what sets them. The SML modules do the same, and in the same order.
void EverySecond() {
    uiApplyBegin();

    // A fresh page render throws away unsaved edits.
    if (ui_reloading) { puffer_laden(); }

    if (ui_applying) {
        // ⚠️ Refuse a source this firmware cannot serve, and put the menu
        // back -- a pulldown that keeps showing a setting which never took
        // effect is worse than one that springs back.
        if (e_quelle == Q_USB && hat_usb == 0) {
            e_quelle = Q_SER;
            addLog("no USB host in this firmware — staying on pins");
        }
        quelle = e_quelle;
        p_rx = e_rx;
        p_tx = e_tx;
        p_baud = baudliste[e_wahl];
        baudwahl = e_wahl;
        // Close what was open; TaskLoop opens the new source on its next pass.
        if (sh >= 0) { serialClose(sh); sh = -1; }
        offen = 0;
        char ms[100];
        if (quelle == Q_USB) {
            strcpy(ms, "bridge source: usb host");
        } else {
            sprintf(ms, "bridge source: pins rx %d tx %d, %d baud",
                    p_rx, p_tx, p_baud);
        }
        addLog(ms);
        puffer_laden();
    }
}

// ─── The submenu ────────────────────────────────────────────────────────
//
// Reachable from the "Brücke" button on the main page. The look is the SML
// family's: framed panel, language switch on top, sections with an icon, Save
// at the bottom, grey footer. Copied by USING their file, not their markup.
//
// ⚠️ THE PIN LISTS COME FROM TASMOTA (`@getfreepins`), not from a hand-written
// list of numbers. It offers exactly the GPIOs that are still free, marks one
// that is in use, and stores the GPIO NUMBER (not an index) -- so it lines up
// with the -1 = "not set" this script already used. A hand-written list would
// go stale the moment a pin is given to something else.
void panel_auf(char icon[], char en[], char de[]) {
    webSend("<style>body>div{width:480px;max-width:96vw;box-sizing:border-box}.sml-p{max-width:100%;box-sizing:border-box;margin:12px auto;background:#f0f0f0;color:#000;padding:16px;border:2px solid #ccc;border-radius:6px;text-align:left}.sml-p h3{margin:0 0 8px}.sml-p hr{border:0;border-top:1px solid #bbb;margin:12px 0}.sml-p b{display:inline-block;margin-bottom:4px;font-size:13px}.sml-p div{margin:6px 0}.sml-p .hint{font-size:9px;color:#555;line-height:1.4}</style>");
    if (ui_de) sprintf(w_buf, "<div class='sml-p'><h3>%s %s</h3>", icon, de);
    else       sprintf(w_buf, "<div class='sml-p'><h3>%s %s</h3>", icon, en);
    webSend(w_buf);
}

void WebUI() {
    if (webPage() != 0) { return; }
    panel_auf("&#x1F50C;", "VarioLab bridge", "VarioLab-Br&uuml;cke");
    uiLang();                       // topmost — it relabels everything below

    uiHead("&#x1F517;", "Source", "Quelle", "");
    if (ui_de) webPulldown(e_quelle, "Anschluss", "USB-Host|Serielle Pins");
    else       webPulldown(e_quelle, "Connection", "USB host|Serial pins");
    if (hat_usb == 0) {
        if (ui_de) webSend("<div class='hint'>&#9888; Diese Firmware hat keinen USB-Host — nur Pins.</div>");
        else       webSend("<div class='hint'>&#9888; No USB host in this firmware — pins only.</div>");
    }

    uiHead("&#x1F50C;", "Serial pins", "Serielle Pins", "");
    if (ui_de) {
        webPulldown(e_rx, "RX (vom Ger&auml;t)", "@getfreepins");
        webPulldown(e_tx, "TX (zum Ger&auml;t)", "@getfreepins");
        webPulldown(e_wahl, "Baudrate",
                    "9600|19200|38400|57600|115200|230400|460800|921600");
        webSend("<div class='hint'>Nur f&uuml;r „Serielle Pins&ldquo;. Andere Baudraten &uuml;ber die Konsole: TCUS ser &lt;rx&gt; &lt;tx&gt; &lt;baud&gt;</div>");
    } else {
        webPulldown(e_rx, "RX (from device)", "@getfreepins");
        webPulldown(e_tx, "TX (to device)", "@getfreepins");
        webPulldown(e_wahl, "Baud rate",
                    "9600|19200|38400|57600|115200|230400|460800|921600");
        webSend("<div class='hint'>Only for &ldquo;Serial pins&rdquo;. Other rates from the console: TCUS ser &lt;rx&gt; &lt;tx&gt; &lt;baud&gt;</div>");
    }

    // ⚠️ THIS BLOCK HAS TO SWITCH LANGUAGE TOO. It was German-only at first,
    // which on an English page reads like a bug in the translation rather than
    // a line somebody forgot -- and it is the line one actually reads.
    uiHead("&#x1F4E1;", "Status", "Zustand", "");
    if (quelle == Q_USB) {
        if (ui_de) {
            sprintf(w_buf, "<div class='hint'>TCP %d &middot; USB-Host, Zustand %d &middot; %d B empfangen, %d B gesendet</div>",
                    horcht, usbState(), usbInfo(2), usbInfo(3));
        } else {
            sprintf(w_buf, "<div class='hint'>TCP %d &middot; USB host, state %d &middot; %d B in, %d B out</div>",
                    horcht, usbState(), usbInfo(2), usbInfo(3));
        }
    } else {
        if (ui_de) {
            sprintf(w_buf, "<div class='hint'>TCP %d &middot; Pins rx %d tx %d, %d Bd &middot; %d B empfangen, %d B gesendet</div>",
                    horcht, p_rx, p_tx, p_baud, s_rx, s_tx);
        } else {
            sprintf(w_buf, "<div class='hint'>TCP %d &middot; pins rx %d tx %d, %d Bd &middot; %d B in, %d B out</div>",
                    horcht, p_rx, p_tx, p_baud, s_rx, s_tx);
        }
    }
    webSend(w_buf);

    webSend("<hr>");
    uiSaveButton();

    uiUptime();
    webSend("usb_variolab_tcp.tc &middot; TinyC <span id='tcv'>...</span></div></div>");
    webSend("<script>if(window.__tcv){document.getElementById('tcv').textContent=window.__tcv;}else{fetch('/tc_api?cmd=status').then(function(r){return r.json();}).then(function(j){window.__tcv=j.release;var e=document.getElementById('tcv');if(e)e.textContent=j.release;}).catch(function(){});}</script>");
}

void TaskLoop() {
    int n;
    int etwas = 0;

    // 0) Keep the TCP server alive.
    // ⚠️ Once a second, not every pass: a failing attempt allocates and frees
    // a WiFiServer, and this loop runs thousands of times per second.
    if (horcht == 0) {
        if (millis() >= naechster_versuch) {
            naechster_versuch = millis() + 1000;
            if (tcpServer(PORT) == 0) {
                horcht = 1;
                addLog("VarioLab bridge listening — TCP 2000");
            }
        }
        return;            // nothing to pump while there is no server
    }

    // ⚠️⚠️ AND RE-ARM IT NOW AND THEN, because `horcht` is a MEMORY and not a
    // measurement. There is no call that asks "is the listener still there",
    // so once the flag was set the script believed it forever -- and on
    // 2026-09-13 it was wrong: unloading an unrelated slot tore the shared
    // server down (fixed in tc_udp_stop, but that is the firmware's half),
    // TCUS still reported "tcp 1", and port 2000 answered "connection
    // refused". A status line that cannot be wrong about the one thing it
    // reports is worth more than one that is cheap.
    //
    // ⚠️ ONLY WHILE NOBODY IS CONNECTED. tcpServer() stops the current client
    // before it rebinds -- doing this under a running measurement would cut
    // the recording in half.
    if (millis() >= naechster_versuch) {
        naechster_versuch = millis() + 30000;
        if (tcpConnected() == 0) { tcpServer(PORT); }
    }

    // 1) Open the chosen source, and reopen it after a disconnect.
    if (quelle == Q_USB) {
        int z = usbState();
        // ⚠️ AFTER A SCRIPT RESTART THE PORT IS USUALLY ALREADY OPEN (state 3)
        // -- usbClose() never ran, so the driver kept it. A pump that only
        // waits for state 2 waits forever and forwards nothing, while every
        // status row cheerfully says "offen". Seen on 2026-09-12: device open,
        // client connected, traffic on the TCP side, not one byte on the wire.
        if (z == 3) { offen = 1; }
        if (z == 2 && offen == 0) {
            if (usbOpen(BAUD) == 1) {
                offen = 1;
                char m[72];
                sprintf(m, "VarioLab: %x:%x open, %d baud", usbInfo(0), usbInfo(1), BAUD);
                addLog(m);
            }
        }
        if (z < 2) { offen = 0; }
    } else {
        // Pins: one serialBegin() and it stays open. -1 means "not wired up
        // yet" -- then the bridge waits instead of guessing a pin.
        if (sh < 0 && p_rx >= 0 && p_tx >= 0) {
            sh = serialBegin(p_rx, p_tx, p_baud, 3, 1024);
            if (sh >= 0) {
                offen = 1;
                char m2[80];
                sprintf(m2, "VarioLab on pins: rx %d tx %d, %d baud", p_rx, p_tx, p_baud);
                addLog(m2);
            }
        }
        if (sh < 0) { offen = 0; }
    }

    // 2) TCP -> device
    n = tcpAvailable();
    if (n > 0) {
        n = tcpReadArray(b);
        if (n > 0) {
            etwas = 1;
            if (offen == 1) {
                if (quelle == Q_USB) {
                    usbWrite(b, n);
                } else {
                    // ⚠️ serialWriteBytes and not a loop over serialWriteByte:
                    // one syscall per byte costs about 10 us, measured on the S3.
                    serialWriteBytes(sh, b, n);
                    s_tx = s_tx + n;
                }
            }
        }
    }

    // 3) device -> TCP
    if (offen == 1) {
        if (quelle == Q_USB) {
            n = usbAvailable();
            while (n > 0) {
                n = usbRead(b, 1024);
                if (n <= 0) { break; }
                tcpWriteArray(b, n, 0);   // 0 = raw bytes; 1 would be uint16!
                etwas = 1;
                n = usbAvailable();
            }
        } else {
            // ⚠️ serialReadArray fetches the WHOLE block in one syscall.
            // serialRead() would be one per byte: the VM manages 95 969 of
            // those per second (measured on an ESP32-S3), so 23 kB/s would
            // spend a quarter of it just fetching.
            n = serialReadArray(sh, b, 1024);
            while (n > 0) {
                tcpWriteArray(b, n, 0);
                s_rx = s_rx + n;
                etwas = 1;
                n = serialReadArray(sh, b, 1024);
            }
        }
    }

    if (etwas == 1) { letzter_verkehr = millis(); }

    // Idle: give the rest of the system room. With traffic, keep spinning.
    if (etwas == 0) { delay(2); }
}

// ─── TCUS — show the state, and switch the source ───────────────────────
//
//     TCUS                  what is set, and what is going through
//     TCUS usb              USB host (OTG socket)
//     TCUS ser 16 17        serial on RX=16 TX=17, at the stored baud rate
//     TCUS ser 16 17 115200 ... and set the baud rate too
//
// ⚠️ SWITCHING CLOSES THE OLD SOURCE. Leaving a serialBegin() open while the
// USB path runs would hold one of the three serial slots for nothing, and the
// pins would keep driving -- on a board where they are shared that is not a
// leak, it is a short.
void Command(char cmd[]) {
    char m[140];
    char w[24];

    // ⚠️⚠️ THE ARGUMENT ARRIVES WITH A LEADING SPACE. `TCUS ser 16 17` reaches
    // this function as " ser 16 17", so strToken(...,1) is the EMPTY string
    // and every call fell through to the status line -- which looks exactly
    // like an argument being ignored, and sent me hunting for the wrong thing
    // (I first assumed Tasmota upper-cases it; it does not). Logged the raw
    // string once and it was obvious. strTrim() first, then the tokens count
    // from 1 as one would expect.
    strTrim(cmd);
    strToken(w, cmd, ' ', 1);
    strToLower(w);
    if (strcmp(w, "usb") == 0) {
        // ⚠️ Refuse instead of switching into a dead end. Without the driver
        // the USB path can never open, and the status page would blame the
        // OTG socket for something the firmware simply does not contain.
        if (hat_usb == 0) {
            responseCmnd("no USB host in this firmware — pins only");
            return;
        }
        if (sh >= 0) { serialClose(sh); sh = -1; }
        quelle = Q_USB;
        offen = 0;
        puffer_laden();
        responseCmnd("source: usb host");
        return;
    }
    if (strcmp(w, "ser") == 0) {
        char a1[12];
        char a2[12];
        char a3[12];
        strToken(a1, cmd, ' ', 2);
        strToken(a2, cmd, ' ', 3);
        int rx = strToInt(a1);
        int tx = strToInt(a2);
        // ⚠️ strToInt gives 0 for anything unreadable, and 0 IS a valid GPIO.
        // So an empty argument has to be caught before, not after -- otherwise
        // "TCUS ser" silently wires the bridge to pin 0, which on most boards
        // is the boot strap.
        if (strlen(a1) == 0 || strlen(a2) == 0) {
            responseCmnd("usage: TCUS ser <rx> <tx> [baud]");
            return;
        }
        if (strToken(a3, cmd, ' ', 4) > 0) {
            int bd = strToInt(a3);
            if (bd > 0) { p_baud = bd; }
        }
        if (sh >= 0) { serialClose(sh); sh = -1; }
        p_rx = rx;
        p_tx = tx;
        quelle = Q_SER;
        offen = 0;
        // ⚠️ Keep the menu in step. Without this the page would show 230400
        // next to a bridge running at 31250 -- and the page is what one
        // believes.
        baudwahl = baud_index(p_baud);
        puffer_laden();
        sprintf(m, "source: serial, rx %d tx %d, %d baud", p_rx, p_tx, p_baud);
        responseCmnd(m);
        return;
    }

    // ⚠️ `horcht` belongs in this line. Without it the status looked healthy
    // while port 2000 was refused -- the one thing it could not tell you was
    // the one thing that was wrong.
    if (quelle == Q_USB) {
        sprintf(m, "tcp %d, source usb, state %d, open %d, rx %d, tx %d, lost %d",
                horcht, usbState(), offen, usbInfo(2), usbInfo(3), usbInfo(4));
    } else {
        sprintf(m, "tcp %d, source serial rx %d tx %d %d bd, handle %d, rx %d, tx %d",
                horcht, p_rx, p_tx, p_baud, sh, s_rx, s_tx);
    }
    responseCmnd(m);
}

// ─── Status on Tasmota's main page ──────────────────────────────────────
//
// WebCall() is Tasmota's FUNC_WEB_SENSOR: it runs about once a second, but
// ONLY while somebody has the main page open. That makes it the right place
// to work out the throughput as well — the bridge keeps running totals in
// usbInfo(2) and usbInfo(3), and all we need is how much they moved since
// the last call.
//
// ⚠️ Whoever reads this page is usually asking one of two questions: "is the
// device even there?" and "is anything happening?". The rows are ordered to
// answer them in that order, and the second is the reason the throughput is
// here at all — a totals counter alone cannot tell a live link from a dead
// one that carried a lot of traffic this morning.
void WebCall() {
    char m[200];
    // ⚠️ The counters come from a different place per source: usbInfo() knows
    // only the USB host, the pin path counts for itself. Reading usbInfo() on
    // a serial bridge would print 0 B/s next to a stream that is flowing --
    // the worst kind of status row, because it looks like a measurement.
    int z = 0;
    int rx = 0;
    int tx = 0;
    if (quelle == Q_USB) {
        z = usbState();
        rx = usbInfo(2);
        tx = usbInfo(3);
    } else {
        rx = s_rx;
        tx = s_tx;
    }

    // ── Throughput from the change since the last call ──────────────────
    // ⚠️ Guard both ends: a page opened after a long pause gives a huge dt
    // (and `delta * 1000` could overflow int32), and a counter that wrapped
    // gives a negative delta. Both cases just re-arm instead of printing a
    // nonsense rate.
    int dt = millis() - w_ms;
    if (dt >= 500) {
        int d_ein = rx - w_rx;
        int d_aus = tx - w_tx;
        if (dt > 5000 || d_ein < 0 || d_aus < 0) {
            w_ein = 0;
            w_aus = 0;
        } else {
            w_ein = d_ein * 1000 / dt;
            w_aus = d_aus * 1000 / dt;
        }
        w_rx = rx;
        w_tx = tx;
        w_ms = millis();
    }

    // ── 1. Is the device there? ─────────────────────────────────────────
    if (quelle == Q_SER) {
        if (sh >= 0) {
            sprintf(m, "{s}VarioLab{m}<b style='color:#0a0'>Pins offen</b>"
                       " &middot; rx %d tx %d &middot; %d Bd{e}", p_rx, p_tx, p_baud);
        } else if (p_rx < 0) {
            strcpy(m, "{s}VarioLab{m}<b style='color:#d80'>Pins nicht gesetzt</b>"
                      " &middot; TCUS ser &lt;rx&gt; &lt;tx&gt;{e}");
        } else {
            sprintf(m, "{s}VarioLab{m}<b style='color:#c00'>Pins lassen sich nicht "
                       "&ouml;ffnen</b> &middot; rx %d tx %d{e}", p_rx, p_tx);
        }
    } else if (z == 3) {
        sprintf(m, "{s}VarioLab{m}<b style='color:#0a0'>offen</b>"
                   " &middot; %04x:%04x &middot; %d Bd{e}",
                usbInfo(0), usbInfo(1), BAUD);
    } else if (z == 2) {
        sprintf(m, "{s}VarioLab{m}<b style='color:#d80'>erkannt</b>"
                   " &middot; %04x:%04x &middot; noch nicht ge&ouml;ffnet{e}",
                usbInfo(0), usbInfo(1));
    } else if (z == 1) {
        // ⚠️ Fixed texts go straight out: sprintf wants at least one value,
        // otherwise it does not compile at all.
        strcpy(m, "{s}VarioLab{m}<b style='color:#888'>kein Ger&auml;t</b> an der OTG-Buchse{e}");
    } else if (z == 4) {
        strcpy(m, "{s}VarioLab{m}<b style='color:#c00'>Fehler</b> beim &Ouml;ffnen{e}");
    } else {
        strcpy(m, "{s}VarioLab{m}<b style='color:#c00'>USB-Host nicht angemeldet</b>{e}");
    }
    webSend(m);

    // ── 2. Who is on the other end? ─────────────────────────────────────
    // The two rows above and below answer "is the device there" and "is
    // anything moving"; this one sits between them because it is the third
    // thing that can be missing — the bridge can be perfect and still have
    // nobody talking to it.
    //
    // ⚠️ tcpConnected() only READS. Do not reach for tcpAvailable() here:
    // that one accepts a pending client as a side effect, and a status row
    // has no business changing what it reports on.
    if (tcpConnected() == 1) {
        sprintf(m, "{s}Gegenstelle{m}<b style='color:#0a0'>verbunden</b>"
                   " &middot; Port %d{e}", PORT);
    } else {
        // Why the last connection ended says more than its absence:
        // "hung up" is the normal case after a session, "timeout" and
        // "network gone" are not.
        int grund = tcpDisconnectReason();
        if (grund == 2) {
            sprintf(m, "{s}Gegenstelle{m}<span style='color:#888'>wartet auf"
                       " Port %d</span> &middot; zuletzt: hat aufgelegt{e}", PORT);
        } else if (grund == 3) {
            sprintf(m, "{s}Gegenstelle{m}<span style='color:#d80'>wartet auf"
                       " Port %d</span> &middot; zuletzt: Zeit&uuml;berschreitung{e}", PORT);
        } else if (grund == 4) {
            sprintf(m, "{s}Gegenstelle{m}<span style='color:#c00'>wartet auf"
                       " Port %d</span> &middot; zuletzt: Netz weg{e}", PORT);
        } else {
            sprintf(m, "{s}Gegenstelle{m}<span style='color:#888'>wartet auf"
                       " Port %d</span>{e}", PORT);
        }
    }
    webSend(m);

    // ── 3. Is anything happening? ───────────────────────────────────────
    // ⚠️ Silence is shown in SECONDS, not as "nothing". A stream that broke
    // off two seconds ago otherwise looks like one that never ran.
    int still = millis() - letzter_verkehr;
    if (letzter_verkehr == 0) {
        strcpy(m, "{s}Datenstrom{m}<span style='color:#888'>noch nichts geflossen</span>{e}");
    } else if (still > 3000) {
        sprintf(m, "{s}Datenstrom{m}<span style='color:#888'>still seit %d s</span>{e}", still / 1000);
    } else {
        sprintf(m, "{s}Datenstrom{m}%d B/s &larr; Ger&auml;t"
                   " &middot; %d B/s &rarr; Ger&auml;t{e}", w_ein, w_aus);
    }
    webSend(m);

    // ── 4. What has gone through in total ───────────────────────────────
    sprintf(m, "{s}&Uuml;bertragen{m}%d B empfangen &middot; %d B gesendet{e}",
            rx, tx);
    webSend(m);

    // ── 5. Losses, and only then ────────────────────────────────────────
    // ⚠️ A row that is always there gets read as decoration. This one shows
    // up only when something IS lost -- and then it means a shifted frame,
    // not a missing value, so it is worth the red.
    if (usbInfo(4) > 0) {
        sprintf(m, "{s}Verloren{m}<b style='color:#c00'>%d B</b> &mdash; der Ring lief &uuml;ber{e}", usbInfo(4));
        webSend(m);
    }
}