slcan_bridge_tcp.tc¶
slcan_bridge_tcp.tc — SLCAN bridge over TCP (WiFi).
// =================================================================
// slcan_bridge_tcp.tc — SLCAN bridge over TCP (WiFi).
//
// Same protocol as `slcan_bridge.tc` (Lawicel-CAN232 ASCII subset),
// but transport is a TCP server on port 8888 instead of USB-CDC /
// UART. Mac client connects via socket → bridge translates to/from
// TWAI / CAN bus.
//
// Why TCP instead of serial:
// • The ESP32-C3's USB peripheral is normally claimed by Tasmota
// for the console. Repurposing it for SLCAN data either races
// Tasmota's command parser (and loses) or requires firmware-side
// surgery (suppress console reads, expose USB-Serial-JTAG
// read/write to TinyC).
// • Going over WiFi has ~5-15 ms latency on a healthy LAN —
// irrelevant for SML CAN traffic at 1-10 Hz frame rates, fine
// even at saturating bus rates for typical use.
// • Tasmota's USB console keeps working independently — debug
// `addLog` lines from the bridge in your terminal *while* SLCAN
// traffic flows over WiFi.
//
// Loopback test (NO transceiver — but you DO need one wire):
// Set twai_mode = 1 below, drop a single jumper between
// `can_tx_pin` and `can_rx_pin` on the bridge ESP32, redeploy.
//
// Mechanism: the TinyC dispatcher sets msg.self = 1 on every
// twai_transmit (when twai_mode==1), so the controller queues
// each TX'd frame straight back into the RX queue when it sees
// the same bits return on RX. The jumper closes the
// TX-pin → RX-pin loop electrically — without it the C3's TWAI
// peripheral has no way to "see" its own bits regardless of the
// self flag. (We confirmed empirically on a generic C3 board:
// self=1 + NO_ACK + no wire = TX queue fills and never drains.
// Add the wire and every frame loops back.)
//
// Don't be tempted to skip the jumper "because msg.self looks
// software-only" — it's not. ESP-IDF docs are explicit that
// self-reception requires "an external transceiver to internally
// loopback the TX to RX such that a change in logic level to the
// TX signal line can be observed on the RX line".
//
// Mac client:
// python slcan_loopback_test.py 192.168.188.<bridge_ip>:8888 \
// --frames 50
// =================================================================
// ── Pin + TCP port config ────────────────────────────────────
// GPIO 9 + 10 chosen because they're free on the .143 dev-board
// where 6/7 are reserved by the existing module template. Any pair
// of free GPIOs works — TWAI goes through the C3's IOMUX, no
// hardwired pin assignment.
int can_rx_pin = 9; // ESP32-C3 .143 wiring 2026-05-16: RX=GPIO9, TX=GPIO10
int can_tx_pin = 10; // NOTE: GPIO9 is the BOOT strap. RXD idles HIGH
// (recessive) so it should NOT force download
// mode at reset — but watch the next reboot.
int tcp_port = 9999; // Mac client connects to this port
// (8888 collided with another listener on
// the test C3; 9999 confirmed clean)
int bitrate = 250; // kbit/s, set via S<n> command before O
int twai_open = 0; // 1 after twaiBegin succeeded
int tcp_started = 0; // 1 after tcpServer() bound (needs WiFi up)
int twst[7]; // twaiStatus: [state,txe,rxe,txf,rxm,arb,buse]
// ── Loopback / test mode ─────────────────────────────────────
//
// 0 = NORMAL — production. Requires transceiver + at least one
// peer node on the bus to ACK each TX'd frame.
// 1 = NO_ACK — Self Test Mode. The controller TXes without ACK
// and **receives its own transmissions internally
// via the TWAI controller's loopback path** — no
// wire, no transceiver, no peer needed.
// 2 = LISTEN_ONLY — sniffer; never TX or ACK.
//
// Set to 1 for the loopback bring-up test, 0 once the transceiver
// is wired up and a real DUT is on the bus.
// 2026-05-16: authentic transceivers soldered, bus differential
// measured ~2.0 V (spec) — electrical layer PASSES. NORMAL mode.
int twai_mode = 0;
// ── SLCAN command parser state ───────────────────────────────
char cmdbuf[80];
int cmdlen = 0;
// ── Activity counters ─────────────────────────────────────────
int tx_frames = 0;
int rx_frames = 0;
int cmd_errors = 0;
int bus_errors = 0;
int client_connected = 0;
// Consecutive twaiSend failures. twaiSend (SYS_TWAI_SEND) returns 0
// when twai_transmit() is non-OK — which is what happens once the
// controller has gone BUS-OFF (a DUT that briefly stops ACKing makes
// the controller auto-retransmit until its TX-error counter avalanches
// to bus-off; the ESP32 TWAI does NOT self-recover). The old
// twaiStatus()==2 check could never fire (that syscall returns 1/0 and
// never exposes the state), so bus-off used to latch until the client
// churned C/S/O. We now recover in place after a short run of failed
// sends — the real, observable bus-off signal.
int tx_fail_run = 0;
// ── Per-frame scratch ────────────────────────────────────────
char tx_data[8];
char rx_data[8];
int rx_meta[3]; // [0]=id, [1]=ext, [2]=dlc
int rx_stats[4];
// ── Network RX scratch (bytes from Mac client) ───────────────
char netbuf[260]; // tcpRead caps at 254 + NUL
// ── TX line builder (sent back to Mac) ───────────────────────
char outline[80];
// ── Tiny hex helpers ─────────────────────────────────────────
int hex_nibble(int c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
int parse_hex(int off, int n_chars) {
int v = 0;
int i = 0;
for (i = 0; i < n_chars; i = i + 1) {
int nb = hex_nibble(cmdbuf[off + i] & 0xff);
if (nb < 0) return -1;
v = (v << 4) | nb;
}
return v;
}
// ── Buffer-builder helpers (write to outline[idx], advance idx) ─
//
// Building the response line in a buffer first, then doing one
// tcpWrite() at the end, keeps the protocol coherent — every
// outgoing message goes as one TCP segment instead of many.
int append_nibble(int idx, int n) {
if (n < 10) outline[idx] = '0' + n;
else outline[idx] = 'a' + n - 10;
return idx + 1;
}
int append_hex8(int idx, int b) {
idx = append_nibble(idx, (b >> 4) & 0xf);
idx = append_nibble(idx, b & 0xf);
return idx;
}
int append_hex11(int idx, int id) {
idx = append_nibble(idx, (id >> 8) & 0x7);
idx = append_nibble(idx, (id >> 4) & 0xf);
idx = append_nibble(idx, id & 0xf);
return idx;
}
int append_hex29(int idx, int id) {
idx = append_nibble(idx, (id >> 28) & 0x1);
idx = append_nibble(idx, (id >> 24) & 0xf);
idx = append_nibble(idx, (id >> 20) & 0xf);
idx = append_nibble(idx, (id >> 16) & 0xf);
idx = append_nibble(idx, (id >> 12) & 0xf);
idx = append_nibble(idx, (id >> 8) & 0xf);
idx = append_nibble(idx, (id >> 4) & 0xf);
idx = append_nibble(idx, id & 0xf);
return idx;
}
void send_byte(int b) {
outline[0] = b & 0xff;
outline[1] = 0;
tcpWrite(outline);
}
void ack() { send_byte(0x0d); } // CR — SLCAN OK
void nack() {
send_byte(0x07); // BEL — SLCAN error
cmd_errors = cmd_errors + 1;
}
// ── SLCAN bitrate mapping ────────────────────────────────────
int slcan_to_kbps(int code) {
if (code == 0) return 25;
if (code == 1) return 50;
if (code == 2) return 50;
if (code == 3) return 100;
if (code == 4) return 125;
if (code == 5) return 250;
if (code == 6) return 500;
if (code == 7) return 800;
if (code == 8) return 1000;
return -1;
}
// ── Process one fully-received SLCAN command line ────────────
void handle_command() {
if (cmdlen <= 0) {
ack();
return;
}
int cmd = cmdbuf[0] & 0xff;
if (cmd == 'S') {
if (cmdlen < 2) { nack(); return; }
int code = hex_nibble(cmdbuf[1] & 0xff);
if (code < 0) { nack(); return; }
int kbps = slcan_to_kbps(code);
if (kbps < 0) { nack(); return; }
// Tolerate S while open: a reconnecting client (its TCP dropped
// ungracefully, no C sent) sends S before O. Old code nack'd
// here → the client could never re-open → broken-pipe churn.
// Treat S-while-open as reconfigure: close, then accept.
if (twai_open) { twaiEnd(); twai_open = 0; }
bitrate = kbps;
ack();
return;
}
if (cmd == 'O') {
if (twai_open) { ack(); return; }
int rc = twaiBegin(can_rx_pin, can_tx_pin, bitrate, twai_mode);
if (rc != 1) {
// twaiBegin can fail with ESP_ERR_INVALID_STATE when the
// TWAI driver is still installed from a prior session that
// didn't cleanly close (script re-run via TinyCRun without
// a reboot, client dropped without sending C, or a bus-off
// left it installed). Tear it down and retry once so O is
// recoverable WITHOUT a device Restart — this is what made
// "bridge did not ACK O / no data flowing" a recurring trap.
twaiEnd();
rc = twaiBegin(can_rx_pin, can_tx_pin, bitrate, twai_mode);
}
if (rc != 1) { nack(); return; }
twai_open = 1;
// A successful O proves a real client opened the channel.
// Make the flag track the S/O..C session lifecycle — NOT the
// flaky per-tick tcpAvailable/tcpConnected heuristic. A passive
// SLCAN responder sends nothing between polls, so a tick-based
// flag decays to 0 and would deadlock the RX-forward gate.
client_connected = 1;
ack();
return;
}
if (cmd == 'C') {
if (twai_open) {
twaiEnd();
twai_open = 0;
}
client_connected = 0;
ack();
return;
}
if (cmd == 't' || cmd == 'T') {
if (!twai_open) { nack(); return; }
int id_chars = (cmd == 't') ? 3 : 8;
if (cmdlen < 2 + id_chars) { nack(); return; }
int id = parse_hex(1, id_chars);
if (id < 0) { nack(); return; }
int dlc = hex_nibble(cmdbuf[1 + id_chars] & 0xff);
if (dlc < 0 || dlc > 8) { nack(); return; }
if (cmdlen < 2 + id_chars + dlc * 2) { nack(); return; }
int p = 0;
for (p = 0; p < dlc; p = p + 1) {
int hi = hex_nibble(cmdbuf[2 + id_chars + p * 2] & 0xff);
int lo = hex_nibble(cmdbuf[2 + id_chars + p * 2 + 1] & 0xff);
if (hi < 0 || lo < 0) { nack(); return; }
tx_data[p] = (hi << 4) | lo;
}
int ext = (cmd == 'T') ? 1 : 0;
int sent = twaiSend(id, ext, dlc, tx_data);
if (sent != 1) {
// twai_transmit() non-OK — almost always BUS-OFF (a DUT that
// briefly stopped ACKing made the controller auto-retransmit
// until its TX-error counter avalanched). ESP32 TWAI does
// not self-recover; reinstall the driver in place after a
// short run so the link comes straight back instead of
// latching dead until the client churns C/S/O.
tx_fail_run = tx_fail_run + 1;
if (tx_fail_run >= 3) {
twaiEnd();
twaiBegin(can_rx_pin, can_tx_pin, bitrate, twai_mode);
bus_errors = bus_errors + 1;
tx_fail_run = 0;
addLog("slcan_bridge_tcp: TWAI bus-off -> reinstalled in place");
}
nack();
return;
}
tx_fail_run = 0;
tx_frames = tx_frames + 1;
// SLCAN spec: ACK as `z\r` / `Z\r`
outline[0] = (cmd == 't') ? 'z' : 'Z';
outline[1] = 0x0d;
outline[2] = 0;
tcpWrite(outline);
return;
}
if (cmd == 'V') { strcpy(outline, "V1010"); outline[5]=0x0d; outline[6]=0; tcpWrite(outline); return; }
if (cmd == 'N') { strcpy(outline, "N0001"); outline[5]=0x0d; outline[6]=0; tcpWrite(outline); return; }
if (cmd == 'F') {
int flags = (bus_errors > 0) ? 0x80 : 0x00;
outline[0] = 'F';
int idx = append_hex8(1, flags);
outline[idx] = 0x0d;
outline[idx+1] = 0;
tcpWrite(outline);
return;
}
if (cmd == 'r' || cmd == 'R') { ack(); return; }
nack();
}
// ── Format one received CAN frame as SLCAN line, send via TCP ──
void emit_rx_frame() {
int id = rx_meta[0];
int ext = rx_meta[1];
int dlc = rx_meta[2];
int idx = 0;
outline[idx++] = (ext ? 'T' : 't');
if (ext) idx = append_hex29(idx, id);
else idx = append_hex11(idx, id);
idx = append_nibble(idx, dlc & 0xf);
int p = 0;
for (p = 0; p < dlc; p = p + 1) {
idx = append_hex8(idx, rx_data[p] & 0xff);
}
outline[idx++] = 0x0d;
outline[idx] = 0;
tcpWrite(outline);
rx_frames = rx_frames + 1;
}
// ── Periodic poll: drain TCP RX, drain CAN RX ────────────────
void Every50ms() {
// 0. If the TCP server isn't up yet (autostart fired before WiFi,
// or WiFi was already up before this script loaded so
// OnWifiConnect won't re-fire), keep retrying until it binds.
if (!tcp_started) { start_tcp_server(); if (!tcp_started) return; }
// 1. Drain SLCAN command bytes from the TCP client.
int avail = tcpAvailable();
if (avail > 0) {
client_connected = 1;
// tcpRead caps at 254, NUL-terminates. Loop until queue drains.
int loop_guard = 8;
while (avail > 0 && loop_guard > 0) {
int n = tcpRead(netbuf);
if (n <= 0) break;
int i = 0;
for (i = 0; i < n; i = i + 1) {
int b = netbuf[i] & 0xff;
if (b == 0x0d) {
handle_command();
cmdlen = 0;
} else if (b == 0x0a) {
// ignore LF
} else if (cmdlen < 79) {
cmdbuf[cmdlen] = b;
cmdlen = cmdlen + 1;
} else {
cmdlen = 0;
cmd_errors = cmd_errors + 1;
}
}
avail = tcpAvailable();
loop_guard = loop_guard - 1;
}
}
// NOTE: do NOT clear client_connected on an idle tick.
// tcpConnected()/tcpAvailable() read false between polls (a passive
// SLCAN responder sends nothing until it gets a frame to answer).
// Clearing here decayed the flag to 0 and the RX-forward gate below
// then never forwarded the DUT's poll → client never answers →
// permanent deadlock. The flag's lifecycle is owned by O (set) /
// C (clear); stale-session cleanup is the S-while-open handler.
// 2. Drain CAN RX. Push frames as SLCAN lines until queue empty
// or we've handled a reasonable batch. Gate on twai_open ONLY —
// if the channel is open a client opened it; writing RX to a
// since-departed client is harmless and a reconnect re-S/Os.
if (twai_open) {
int batch = 16;
while (batch > 0) {
int n = twaiRecv(rx_meta, rx_data, 8);
if (n <= 0) break;
emit_rx_frame();
batch = batch - 1;
}
}
// 3. Bus-off recovery is driven by consecutive twaiSend failures in
// the t/T handler (see tx_fail_run) — that is the only reliable,
// observable bus-off signal from TinyC. The previous
// `twaiStatus(twst) == 2` test here was DEAD CODE: SYS_TWAI_STATUS
// returns 1 (ok) / 0 (err) and fills the array with
// rx/tx/bus_error counts only — it never returns the controller
// state, so `st == 2` was never true and bus-off latched until
// the client churned C/S/O. Removed; do not re-add without first
// extending SYS_TWAI_STATUS to expose twai_status_info_t.state.
}
// ── WebCall — show bridge state on the main page ─────────────
void WebCall() {
char row[120];
char state[12];
if (twai_open) strcpy(state, "OPEN");
else strcpy(state, "closed");
char tcpstate[12];
if (client_connected) strcpy(tcpstate, "client");
else strcpy(tcpstate, "no client");
sprintf(row, "{s}SLCAN bridge (TCP:%d){m}%s, %d kbit/s, %s{e}",
tcp_port, state, bitrate, tcpstate);
webSend(row);
sprintf(row, "{s}Frames RX/TX{m}%d / %d{e}", rx_frames, tx_frames);
webSend(row);
sprintf(row, "{s}Cmd errors / bus errors{m}%d / %d{e}",
cmd_errors, bus_errors);
webSend(row);
}
// ── TCP server start — deferred until WiFi is up ─────────────
// At autostart (~3 s uptime) the network stack isn't ready, so
// tcpServer() returns rc<0 and (previously) the bridge stayed dead
// until a manual TinyCRun. Now: bind from OnWifiConnect() (the
// WiFi-up callback) AND retry from Every50ms (covers the case where
// WiFi was already up before this script auto-loaded, so the
// network-up callback won't fire again). Guarded by tcp_started so
// the socket binds exactly once.
void start_tcp_server() {
if (tcp_started) return;
int rc = tcpServer(tcp_port);
if (rc < 0) { return; } // network not ready — retry later
tcp_started = 1;
char m[96];
sprintf(m, "slcan_bridge_tcp: listening on TCP:%d (twai_mode=%d)",
tcp_port, twai_mode);
addLog(m);
addLog("slcan_bridge_tcp: connect a client and send `S5\\rO\\r` to open at 250 kbit/s");
}
// WiFi/network came up (FUNC_NETWORK_UP) — bind now. Guarded, so a
// later reconnect is a no-op (don't rebind a live socket).
void OnWifiConnect() {
start_tcp_server();
}
// ── main — must return so callbacks activate ─────────────────
int main() {
char m[80];
sprintf(m, "slcan_bridge_tcp: started uptime %d s — waiting for WiFi",
tasm_uptime);
addLog(m);
start_tcp_server(); // binds now if WiFi already up; else deferred
return 0;
}