Skip to content

slcan_bridge_tcp.tc

slcan_bridge_tcp.tc — SLCAN bridge over TCP (WiFi).

Source on GitHub

// =================================================================
// 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;
}