usb_variolab_tcp.tc¶
usb_variolab_tcp.tc — bridges a TCP port to a device, over USB host OR pins.
// @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ü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("🔌", "VarioLab bridge", "VarioLab-Brücke");
uiLang(); // topmost — it relabels everything below
uiHead("🔗", "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'>⚠ Diese Firmware hat keinen USB-Host — nur Pins.</div>");
else webSend("<div class='hint'>⚠ No USB host in this firmware — pins only.</div>");
}
uiHead("🔌", "Serial pins", "Serielle Pins", "");
if (ui_de) {
webPulldown(e_rx, "RX (vom Gerät)", "@getfreepins");
webPulldown(e_tx, "TX (zum Gerät)", "@getfreepins");
webPulldown(e_wahl, "Baudrate",
"9600|19200|38400|57600|115200|230400|460800|921600");
webSend("<div class='hint'>Nur für „Serielle Pins“. Andere Baudraten über die Konsole: TCUS ser <rx> <tx> <baud></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 “Serial pins”. Other rates from the console: TCUS ser <rx> <tx> <baud></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("📡", "Status", "Zustand", "");
if (quelle == Q_USB) {
if (ui_de) {
sprintf(w_buf, "<div class='hint'>TCP %d · USB-Host, Zustand %d · %d B empfangen, %d B gesendet</div>",
horcht, usbState(), usbInfo(2), usbInfo(3));
} else {
sprintf(w_buf, "<div class='hint'>TCP %d · USB host, state %d · %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 · Pins rx %d tx %d, %d Bd · %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 · pins rx %d tx %d, %d Bd · %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 · 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>"
" · rx %d tx %d · %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>"
" · TCUS ser <rx> <tx>{e}");
} else {
sprintf(m, "{s}VarioLab{m}<b style='color:#c00'>Pins lassen sich nicht "
"öffnen</b> · rx %d tx %d{e}", p_rx, p_tx);
}
} else if (z == 3) {
sprintf(m, "{s}VarioLab{m}<b style='color:#0a0'>offen</b>"
" · %04x:%04x · %d Bd{e}",
usbInfo(0), usbInfo(1), BAUD);
} else if (z == 2) {
sprintf(m, "{s}VarioLab{m}<b style='color:#d80'>erkannt</b>"
" · %04x:%04x · noch nicht geö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ät</b> an der OTG-Buchse{e}");
} else if (z == 4) {
strcpy(m, "{s}VarioLab{m}<b style='color:#c00'>Fehler</b> beim Ö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>"
" · 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> · zuletzt: hat aufgelegt{e}", PORT);
} else if (grund == 3) {
sprintf(m, "{s}Gegenstelle{m}<span style='color:#d80'>wartet auf"
" Port %d</span> · zuletzt: Zeitüberschreitung{e}", PORT);
} else if (grund == 4) {
sprintf(m, "{s}Gegenstelle{m}<span style='color:#c00'>wartet auf"
" Port %d</span> · 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 ← Gerät"
" · %d B/s → Gerät{e}", w_ein, w_aus);
}
webSend(m);
// ── 4. What has gone through in total ───────────────────────────────
sprintf(m, "{s}Übertragen{m}%d B empfangen · %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> — der Ring lief über{e}", usbInfo(4));
webSend(m);
}
}