Zum Inhalt

slcan_bridge.tc

slcan_bridge.tc — USB-serial ↔ CAN-bus bridge using the SLCAN

Source on GitHub

// =================================================================
// slcan_bridge.tc — USB-serial ↔ CAN-bus bridge using the SLCAN
// (Lawicel-CAN232) ASCII protocol.
//
// Hardware: ESP32-C3 + CAN transceiver (SN65HVD230 / TJA1051 /
// MCP2562 — anything 3.3V-compatible). C3 has built-in TWAI; no
// MCP2515 SPI needed. USB-CDC for the host serial.
//
// Wire-up (typical C3 dev board):
//   GPIO 6  → CAN_TX → transceiver TXD
//   GPIO 7  → CAN_RX ← transceiver RXD
//   transceiver CAN_H / CAN_L → bus, 120 Ω termination at both ends
//
// Host side: any SLCAN client. Tested patterns:
//   python -m can.viewer -i slcan -c /dev/cu.usbmodem* -b 250000
//   python -c "import can; b=can.Bus('/dev/…', interface='slcan',
//       bitrate=250000); print(b.recv(timeout=5))"
//   slcand /dev/ttyUSB0 + can-utils  (Linux)
//
// Protocol (subset — all that python-can / can-utils need):
//   S0..S8\r        set bitrate 10/20/50/100/125/250/500/800/1000 kbit/s
//                     (bridge maps S0→25k as 10k isn't a TWAI preset)
//   O\r             open the channel — calls twaiBegin
//   C\r             close — twaiEnd
//   t<iii><L><DD…>\r       transmit 11-bit frame, ID hex (3 chars), DLC,
//                          payload bytes hex
//   T<iiiiiiii><L><DD…>\r  transmit 29-bit extended frame, ID hex (8 chars)
//   V\r             firmware version → "V1010\r"
//   N\r             serial number    → "N0001\r"
//   F\r             status flags     → "F00\r" (always-OK)
//   r/R \r          (RTR — accepted but not actually transmitted)
//
// Output (RX) format mirrors TX: lines starting with t/T followed by
// ID + DLC + payload + CR. Each ACK is a single CR (0x0D); each NACK
// is BEL (0x07).
//
// Default bitrate is 250 kbit/s — same as Sorel XHCC/LTDC. Override
// before issuing `O`: e.g. `S5\rO\r` for 125 kbit/s (Huawei R4850G2).
//
// =================================================================

// ── Pin + serial config ──────────────────────────────────────
int  can_rx_pin = 7;
int  can_tx_pin = 6;
int  cdc_baud   = 115200;       // ignored by USB-CDC but conventional
int  ser        = -1;
int  bitrate    = 250;          // kbit/s, set via S<n> command before O
int  twai_open  = 0;            // 1 after twaiBegin succeeded

// ── Loopback / test mode ─────────────────────────────────────
//
// twai_mode controls the TWAI controller's bus-state behaviour at
// twaiBegin time:
//
//   0 = NORMAL      — production. Requires a transceiver + at least
//                     one peer node on the bus (real DUT or another
//                     ESP32) to ACK each transmitted frame. Without a
//                     peer, transmissions retry forever and never
//                     succeed.
//
//   1 = NO_ACK      — Self Test Mode (ESP-IDF terminology). The
//                     controller transmits without waiting for an ACK
//                     **and the TinyC dispatcher sets msg.self=1 on
//                     each twai_transmit** so the controller counts
//                     each TX'd frame as a self-reception when it
//                     sees the same bits back on RX. **You still need
//                     a single jumper wire** between the configured
//                     CAN-TX and CAN-RX GPIOs to electrically close
//                     the loop — the ESP32 TWAI peripheral cannot
//                     see its own bits without a physical TX→RX
//                     path. With the wire in place every frame
//                     loops back; without it the TX queue fills
//                     and never drains. Empirically confirmed on
//                     ESP32-C3 (verified on .143, 2026-05-09).
//
//   2 = LISTEN_ONLY — receive-only sniffer. Never transmits, never
//                     ACKs. Useful for passive CAN bus monitoring on
//                     a live bus that already has another ACK source.
//
// Set this to 1 before flashing for the loopback bring-up test, then
// back to 0 once the transceiver is wired up.
int  twai_mode  = 0;            // 0 = NORMAL (default); 1 = NO_ACK (loopback test)

// ── SLCAN command parser state ───────────────────────────────
char cmdbuf[80];               // up to ~70 hex chars + framing
int  cmdlen = 0;

// ── Activity counters / diag ─────────────────────────────────
int  tx_frames  = 0;
int  rx_frames  = 0;
int  cmd_errors = 0;
int  bus_errors = 0;

// ── Per-frame scratch ────────────────────────────────────────
//
// `rx_meta[3]` holds (id, ext, dlc) — bundled into one array because
// twaiRecv passes references via array elements rather than scalar
// addresses-of-globals (TinyC's syscall ABI is array-based).
char tx_data[8];
char rx_data[8];
int  rx_meta[3];        // [0]=id, [1]=ext, [2]=dlc
int  rx_stats[4];       // [0]=rx_q, [1]=tx_q, [2]=err

// ── Tiny hex helpers ─────────────────────────────────────────
//
// `hex_nibble`: '0'..'9'/'a'..'f'/'A'..'F' → 0..15, anything else → -1
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;
}

// Parse `n_chars` hex digits starting at cmdbuf[off] → return value, or
// -1 on bad digit. Caller checks length before calling.
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;
}

// Emit one hex nibble (0..15) on the host serial.
void emit_nibble(int n) {
    int b;
    if (n < 10)  b = '0' + n;
    else         b = 'a' + n - 10;
    serialWriteByte(ser, b);
}

void emit_hex8(int b) {
    emit_nibble((b >> 4) & 0xf);
    emit_nibble(b & 0xf);
}

void emit_hex11(int id) {
    emit_nibble((id >> 8) & 0x7);   // 11-bit ID = 3 hex chars
    emit_nibble((id >> 4) & 0xf);
    emit_nibble(id & 0xf);
}

void emit_hex29(int id) {
    emit_nibble((id >> 28) & 0x1);  // 29-bit ID = 8 hex chars
    emit_nibble((id >> 24) & 0xf);
    emit_nibble((id >> 20) & 0xf);
    emit_nibble((id >> 16) & 0xf);
    emit_nibble((id >> 12) & 0xf);
    emit_nibble((id >> 8)  & 0xf);
    emit_nibble((id >> 4)  & 0xf);
    emit_nibble(id & 0xf);
}

void ack()  { serialWriteByte(ser, 0x0d); }    // CR — SLCAN OK
void nack() {
    serialWriteByte(ser, 0x07);                // BEL — SLCAN error
    cmd_errors = cmd_errors + 1;
}

// ── SLCAN bitrate mapping ────────────────────────────────────
//
// S<n> command codes per Lawicel spec:
//   S0=10k  S1=20k  S2=50k  S3=100k  S4=125k
//   S5=250k S6=500k S7=800k S8=1M
// TWAI presets don't include 10k/20k — closest fallback for S0/S1
// is 25k/50k. Most users set S5..S8 (the practical bus rates),
// so the 10k/20k workaround is a corner case.
int slcan_to_kbps(int code) {
    if (code == 0) return 25;     // ← 10k requested, give 25k (closest)
    if (code == 1) return 50;     // ← 20k requested, give 50k (closest)
    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 ────────────
//
// `cmdbuf` holds the bytes between two CRs (CR is the framing terminator;
// not included in cmdbuf). cmdlen = number of bytes. The first byte is
// the SLCAN command letter.
void handle_command() {
    if (cmdlen <= 0) {
        ack();   // empty line — be lenient, return CR
        return;
    }
    int cmd = cmdbuf[0] & 0xff;

    // ── S<n> — set bitrate ───────────────────────────────
    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; }
        if (twai_open) {
            // SLCAN spec says S is rejected while channel open — return
            // BEL; host should send C first.
            nack();
            return;
        }
        bitrate = kbps;
        ack();
        return;
    }

    // ── O — open channel ─────────────────────────────────
    if (cmd == 'O') {
        if (twai_open) { ack(); return; }      // already open
        int rc = twaiBegin(can_rx_pin, can_tx_pin, bitrate, twai_mode);
        if (rc != 1)   { nack(); return; }
        twai_open = 1;
        ack();
        return;
    }

    // ── C — close channel ────────────────────────────────
    if (cmd == 'C') {
        if (twai_open) {
            twaiEnd();
            twai_open = 0;
        }
        ack();
        return;
    }

    // ── t / T — transmit standard / extended frame ──────
    if (cmd == 't' || cmd == 'T') {
        if (!twai_open)         { nack(); return; }
        int id_chars = (cmd == 't') ? 3 : 8;
        // Need: 1 (cmd) + id_chars + 1 (dlc) at minimum
        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; }
        // Need: dlc * 2 hex chars of payload
        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) { nack(); return; }
        tx_frames = tx_frames + 1;
        // SLCAN spec: ACK with the response letter `z\r` (std) or
        // `Z\r` (ext) instead of plain CR. Many parsers accept plain
        // CR too — emit the spec form to be strict.
        serialWriteByte(ser, (cmd == 't') ? 'z' : 'Z');
        serialWriteByte(ser, 0x0d);
        return;
    }

    // ── V — version ──────────────────────────────────────
    if (cmd == 'V') {
        serialWriteByte(ser, 'V');
        serialWriteByte(ser, '1'); serialWriteByte(ser, '0');
        serialWriteByte(ser, '1'); serialWriteByte(ser, '0');
        serialWriteByte(ser, 0x0d);
        return;
    }

    // ── N — serial number ────────────────────────────────
    if (cmd == 'N') {
        serialWriteByte(ser, 'N');
        serialWriteByte(ser, '0'); serialWriteByte(ser, '0');
        serialWriteByte(ser, '0'); serialWriteByte(ser, '1');
        serialWriteByte(ser, 0x0d);
        return;
    }

    // ── F — status flags ─────────────────────────────────
    if (cmd == 'F') {
        // bit 0 = RX FIFO full, bit 1 = TX FIFO full, bit 2 = error
        // warning, bit 3 = data overrun, bit 5 = error passive,
        // bit 6 = arbitration lost, bit 7 = bus error.
        // We don't do detailed accounting yet; emit 00 if no recent
        // bus_errors, otherwise 80 (bus error sticky bit).
        int flags = (bus_errors > 0) ? 0x80 : 0x00;
        serialWriteByte(ser, 'F');
        emit_hex8(flags);
        serialWriteByte(ser, 0x0d);
        return;
    }

    // ── r / R — RTR frames (accepted but not actually transmitted) ──
    if (cmd == 'r' || cmd == 'R') {
        // SML descriptors don't use RTR. Acknowledge to keep clients
        // happy but skip the actual TX.
        ack();
        return;
    }

    // Unknown command.
    nack();
}

// ── Format one received CAN frame as an SLCAN line ───────────
//
// Layout:  t<iii><L><DD…>\r   for 11-bit
//          T<iiiiiiii><L><DD…>\r for 29-bit
void emit_rx_frame() {
    int rx_id  = rx_meta[0];
    int rx_ext = rx_meta[1];
    int rx_dlc = rx_meta[2];
    serialWriteByte(ser, (rx_ext ? 'T' : 't'));
    if (rx_ext) emit_hex29(rx_id);
    else        emit_hex11(rx_id);
    emit_nibble(rx_dlc & 0xf);
    int p = 0;
    for (p = 0; p < rx_dlc; p = p + 1) {
        emit_hex8(rx_data[p] & 0xff);
    }
    serialWriteByte(ser, 0x0d);
    rx_frames = rx_frames + 1;
}

// ── Periodic poll: drain serial RX, drain CAN RX ─────────────
void Every50ms() {
    // 1. Drain SLCAN command bytes from host serial. Frame on CR.
    int avail = serialAvailable(ser);
    int loop_guard = 256;
    while (avail > 0 && loop_guard > 0) {
        int b = serialRead(ser);
        loop_guard = loop_guard - 1;
        if (b == 0x0d) {
            // End of command. Process and reset buffer.
            handle_command();
            cmdlen = 0;
        } else if (b == 0x0a) {
            // LF — ignore (some terminals send CR+LF)
        } else if (cmdlen < 79) {
            cmdbuf[cmdlen] = b & 0xff;
            cmdlen = cmdlen + 1;
        } else {
            // Buffer overrun — drop the line, consume bytes until next CR.
            cmdlen = 0;
            cmd_errors = cmd_errors + 1;
        }
        avail = serialAvailable(ser);
    }

    // 2. Drain CAN RX. Push frames as SLCAN lines until queue empty
    //    or we've handled a reasonable batch.
    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;
        }
    }
}

// ── 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");
    sprintf(row, "{s}SLCAN bridge{m}%s, %d kbit/s{e}", state, bitrate);
    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);
}

// ── main — open USB-CDC, idle ────────────────────────────────
int main() {
    addLog("slcan_bridge: opening USB-CDC at uptime %d s", tasm_uptime);

    // For ESP32-C3, USB-CDC is the default Tasmota serial console; we
    // open a second serial port via serialBegin only when the device
    // also exposes a UART pair. Tasmota's existing console handler
    // shares the USB-CDC, so for a strict bridge build we want a
    // dedicated serial device. The simplest approach: use UART1 on
    // a USB-UART adapter (most C3 devkits expose UART0 = USB-CDC + a
    // separate UART1). If your board only has USB-CDC, replace `ser`
    // here with the USB-CDC fd (TinyC's serialBegin returns -1 for
    // non-UART; fall back to addLog/console-write).
    ser = serialBegin(20, 21, 115200, 0, 1024);  // GPIO 20 RX, 21 TX
    if (ser < 0) {
        addLog("slcan_bridge: serialBegin failed — wire UART1 pins");
        return 0;
    }

    addLog("slcan_bridge: ready — send `S5\\rO\\r` to open at 250 kbit/s");
    return 0;
}