diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1341f95019..21d5b33aea 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -99,6 +99,8 @@ jobs: target: esp32 - path: 'components/byte90/example' target: esp32s3 + - path: 'components/canopen/can_bridge_example' + target: esp32s3 - path: 'components/canopen/example' target: esp32 - path: 'components/chsc6x/example' diff --git a/components/canopen/can_bridge_example/CMakeLists.txt b/components/canopen/can_bridge_example/CMakeLists.txt new file mode 100644 index 0000000000..5c7547c13e --- /dev/null +++ b/components/canopen/can_bridge_example/CMakeLists.txt @@ -0,0 +1,34 @@ +# The following lines of boilerplate have to be in your project's CMakeLists +# in this exact order for cmake to work correctly +cmake_minimum_required(VERSION 3.20) + +# NOTE: the IDF component manager is intentionally left ENABLED here (unlike most +# espp examples) so it can fetch the managed `espressif/esp_tinyusb` dependency +# declared by the usb_device component. To avoid the manager scanning every espp +# component manifest (some board components declare target-specific constraints +# that would fail on esp32s3), EXTRA_COMPONENT_DIRS is narrowed to just the +# components this example uses; the in-repo espp components there satisfy the +# `espp/*` dependencies locally. +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +set(EXTRA_COMPONENT_DIRS + "../../../components/base_component" + "../../../components/dispatcher" + "../../../components/format" + "../../../components/logger" + "../../../components/stream_frame" + "../../../components/task" + "../../../components/twai" + "../../../components/usb_device" +) + +set( + COMPONENTS + "main esptool_py base_component dispatcher format logger stream_frame task twai usb_device esp_tinyusb" + CACHE STRING + "List of components to include" + ) + +project(can_bridge_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/canopen/can_bridge_example/README.md b/components/canopen/can_bridge_example/README.md new file mode 100644 index 0000000000..e8f3c65072 --- /dev/null +++ b/components/canopen/can_bridge_example/README.md @@ -0,0 +1,78 @@ +# USB <-> CAN Bridge Example + +Turns an ESP32-S3 into a **WebUSB / Web Serial CAN interface**: the hosted +[CAN console web app](https://esp-cpp.github.io/espp/apps/can_console.html) +connects over the native USB and can + +- **send** CAN frames as a normal, ACK-ing bus participant ("master"), and +- **inspect** the bus — stream every received frame; in *listen-only* mode the + node is a passive sniffer that never ACKs or transmits. + +It bridges the ESP32-S3 TWAI (CAN 2.0) controller to the host over USB using the +espp `stream_frame` framing and an `espp::Dispatcher` (this example owns +**module id 5**). The same framed protocol is exposed on both the USB **vendor** +interface (WebUSB) and a **CDC** interface (Web Serial), so the web app can use +either transport. The system console/logs stay on the separate built-in +USB-Serial-JTAG. + +## Wiring + +Connect the TWAI TX/RX GPIOs to a CAN transceiver (e.g. SN65HVD230, TJA1050) on +a properly terminated (120 Ω) bus. Defaults (change in `can_bridge_example.cpp`): + +| Signal | GPIO | +|--------|------| +| TWAI TX | 17 | +| TWAI RX | 16 | + +Listen-only mode monitors an existing bus without a transceiver ACKing, but a +transceiver is still required to receive the differential signal. + +## Protocol (module 5) + +Framed with `stream_frame` and routed by `espp::Dispatcher`. The full base +header order on the wire (all multi-byte fields little-endian) is: + +``` +[magic u16 "OT"][flags u8][module u8][type u8][len u32][payload…][crc32 u32] +``` + +The base header is 9 bytes. `module` is **5** for this bridge. `flags` bit0 = +reply (0 = host→device request, 1 = device→host reply/event), bits 4-7 = +version = 1 — so a request `flags` byte is `0x10` and a reply is `0x11`. (v2 +also defines an optional correlation-id field gated by `flags` bit1, inserted +between `type` and `len`; the CAN bridge never sets it, so its frames always use +the 9-byte base header.) `crc32` covers the header + payload. Host→device +requests use type high-nibble 5; device→host replies/events use high-nibble D. + +| Type | Dir | Meaning | +|------|-----|---------| +| `0x50` CAN_TX | H→D | transmit a CAN frame | +| `0x51` SET_CONFIG | H→D | `[baudrate u32][mode u8][rsv u8]` (mode 0=normal, 1=listen-only) | +| `0x52` START | H→D | bring the bus up with the current config | +| `0x53` STOP | H→D | take the bus down | +| `0x54` GET_STATUS | H→D | request a STATUS reply | +| `0xD0` CAN_RX | D→H | a received CAN frame | +| `0xD1` OK | D→H | ack | +| `0xD2` ERROR | D→H | `[code u32][utf8 message]` | +| `0xD3` STATUS | D→H | `[baudrate u32][mode u8][running u8][rx u32][tx u32][err u32]` | + +A CAN frame is encoded as `[id u32][flags u8][dlc u8]` optionally followed by +`dlc` data bytes, where `flags` bit0 = extended (29-bit) and bit1 = RTR. The +data bytes are present **only for non-RTR frames**: an RTR frame is just the +6-byte header even when its `dlc` is nonzero (the DLC is the requested response +length, not a data length). A client must therefore append no data for RTR +frames — the bridge encodes and expects none — so the payload is 6 bytes for RTR +and `6 + dlc` (6..14) otherwise. + +The bus starts **stopped**: the host sets baudrate/mode with `SET_CONFIG`, then +`START`. `SET_CONFIG` is rejected while the bus is running (stop first). + +## Build & flash + +``` +idf.py set-target esp32s3 +idf.py build flash monitor +``` + +Then open the CAN console web app and Connect (WebUSB or Web Serial). diff --git a/components/canopen/can_bridge_example/main/CMakeLists.txt b/components/canopen/can_bridge_example/main/CMakeLists.txt new file mode 100644 index 0000000000..4d9c19253e --- /dev/null +++ b/components/canopen/can_bridge_example/main/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRC_DIRS "." + INCLUDE_DIRS "." + REQUIRES usb_device twai dispatcher stream_frame task logger +) diff --git a/components/canopen/can_bridge_example/main/can_bridge_example.cpp b/components/canopen/can_bridge_example/main/can_bridge_example.cpp new file mode 100644 index 0000000000..ae26ead108 --- /dev/null +++ b/components/canopen/can_bridge_example/main/can_bridge_example.cpp @@ -0,0 +1,329 @@ +// USB <-> CAN (TWAI) bridge example. +// +// Turns an ESP32-S3 into a WebUSB / Web Serial CAN interface: the hosted CAN +// console web app connects over the USB vendor interface and can +// - send CAN frames (as a normal, ACK-ing bus participant — "master"), and +// - inspect the bus (stream every received frame; in listen-only mode the +// node is a passive sniffer that never ACKs/transmits). +// +// Both the vendor (WebUSB) and CDC (Web Serial) interfaces carry the SAME +// framed protocol (espp stream_frame codec, routed by an espp::Dispatcher; this +// example owns module id 5 — see can_bridge_protocol.hpp), so the web app can +// connect over either transport. The system console/logs go to the separate +// built-in USB-Serial-JTAG. Wire the TX/RX GPIOs to a CAN transceiver (e.g. +// SN65HVD230) on a terminated bus. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "dispatcher.hpp" +#include "logger.hpp" +#include "stream_frame.hpp" +#include "task.hpp" +#include "twai.hpp" +#include "usb_device.hpp" + +#include "can_bridge_protocol.hpp" + +using namespace std::chrono_literals; +namespace sf = espp::stream_frame; + +// TWAI GPIOs — change to match your board / transceiver wiring. +static constexpr int kCanTxGpio = 17; +static constexpr int kCanRxGpio = 16; + +extern "C" void app_main(void) { + espp::Logger logger({.tag = "CAN Bridge", .level = espp::Logger::Verbosity::INFO}); + logger.info("Starting USB<->CAN bridge example"); + + // --- USB: vendor (WebUSB) for the framed protocol + CDC for the console ---- + espp::UsbDevice::Config usb_cfg; + usb_cfg.manufacturer = "espp"; + usb_cfg.product = "espp CAN Bridge"; + usb_cfg.log_level = espp::Logger::Verbosity::WARN; + espp::UsbDevice::VendorFunction vendor; + vendor.interface_name = "espp CAN Bridge (WebUSB)"; + vendor.webusb = true; + vendor.landing_page_url = "esp-cpp.github.io/espp/apps/can_console.html"; + usb_cfg.vendor = vendor; + // The CDC interface carries the SAME framed protocol as the vendor interface, + // so the web app can connect over Web Serial as well as WebUSB. (The system + // console/logs go to the built-in USB-Serial-JTAG, kept separate — see + // sdkconfig.defaults.) + espp::UsbDevice::CdcFunction cdc; + cdc.interface_name = "espp CAN Bridge (CDC)"; + usb_cfg.cdc = cdc; + espp::UsbDevice usb(usb_cfg); + + // Device -> host frames go to whichever transport the host last talked on + // (only one is connected at a time). Device -> host sends come from TWO tasks + // (the RX worker's request replies and the TWAI receive task's streamed + // frames), so serialize them. + enum class Transport { Vendor, Cdc }; + std::atomic active_transport{Transport::Vendor}; + std::mutex tx_mutex; + auto send = [&](std::span bytes) { + std::lock_guard lock(tx_mutex); + // write_vendor/write_cdc are all-or-nothing (no truncated frame): they + // bounded-wait (~250 ms) for the host to drain the TX FIFO, then return + // false and drop the WHOLE frame if it still does not fit / the host is + // disconnected. We send from ordinary tasks (RX worker + TWAI receive task), + // not the TinyUSB callback, so the drain-wait path applies. For a + // best-effort CAN monitor a drop is acceptable; surface it rate-limited + // rather than silently discarding a reply/CAN_RX frame. + const bool ok = (active_transport.load() == Transport::Cdc) ? usb.write_cdc(bytes) + : usb.write_vendor(bytes); + if (!ok) + logger.warn_rate_limited("dropped a {}-byte frame (USB TX backpressure or disconnect)", + bytes.size()); + }; + auto send_frame = [&](uint8_t type, std::span payload = {}) { + // Reply/event types (kCanRx/kOk/kError/kStatus) carry the high bit; map it + // to the frame reply flag. All CAN-bridge frames are module kModuleId. + const bool reply = (type & 0x80) != 0; + send(sf::build_frame(reply, can_bridge::kModuleId, type, payload)); + }; + auto reply_error = [&](const std::error_code &ec, const std::string &context) { + std::vector p; + sf::put_u32(p, static_cast(ec.value())); + const std::string msg = context + ": " + ec.message(); + p.insert(p.end(), msg.begin(), msg.end()); + send_frame(can_bridge::kError, p); + }; + + // --- CAN bus state (recreated on START so baudrate/mode can change) --------- + std::mutex bus_mutex; // guards twai + config below + std::unique_ptr twai; + uint32_t baudrate = 1000000; + uint8_t mode = can_bridge::kModeNormal; + std::atomic rx_count{0}, tx_count{0}, err_count{0}; + + // TWAI receive task context: stream each frame to the host as CAN_RX. + auto on_can_rx = [&](const espp::Twai::Message &m) { + can_bridge::CanFrame f; + f.id = m.id; + f.extended = m.extended; + f.rtr = m.rtr; + f.dlc = m.dlc; + f.data = m.data; + rx_count.fetch_add(1); + send_frame(can_bridge::kCanRx, can_bridge::encode_frame(f)); + }; + auto on_can_err = [&](twai_error_flags_t) { err_count.fetch_add(1); }; + + auto send_status = [&]() { + std::vector p; + { + std::lock_guard lock(bus_mutex); + const bool running = static_cast(twai); + sf::put_u32(p, baudrate); + p.push_back(mode); + p.push_back(running ? 1 : 0); + } + sf::put_u32(p, rx_count.load()); + sf::put_u32(p, tx_count.load()); + sf::put_u32(p, err_count.load()); + send_frame(can_bridge::kStatus, p); + }; + + auto start_bus = [&](std::error_code &ec) -> bool { + std::lock_guard lock(bus_mutex); + if (twai) + return true; // already running + espp::Twai::Config cfg; + cfg.tx_gpio = kCanTxGpio; + cfg.rx_gpio = kCanRxGpio; + cfg.baudrate = baudrate; + cfg.mode = (mode == can_bridge::kModeListenOnly) ? espp::Twai::Mode::LISTEN_ONLY + : espp::Twai::Mode::NORMAL; + cfg.auto_start = false; // start() explicitly so we get an error code + cfg.on_receive = on_can_rx; + cfg.on_error = on_can_err; + auto node = std::make_unique(cfg); + if (!node->start(ec)) + return false; // node destructs, uninstalling the driver + twai = std::move(node); + return true; + }; + auto stop_bus = [&]() { + std::lock_guard lock(bus_mutex); + if (twai) { + std::error_code ec; + twai->stop(ec); + twai.reset(); + } + }; + + // --- CAN bridge protocol handler (dispatcher module id 5) ------------------ + auto handle_can_frame = [&](const espp::stream_frame::Frame &frame) { + // host->device requests only: ignore reply-flagged frames (0xD_ replies are + // what we SEND; an echoed reply must not re-enter the request handler) + if (frame.is_reply()) + return; + const uint8_t type = frame.type; + std::span payload = frame.payload; + std::error_code ec; + switch (type) { + case can_bridge::kCanTx: { + can_bridge::CanFrame f; + if (!can_bridge::decode_frame(payload, f)) { + reply_error(std::make_error_code(std::errc::invalid_argument), "malformed CAN_TX"); + break; + } + std::lock_guard lock(bus_mutex); + if (!twai) { + reply_error(std::make_error_code(std::errc::not_connected), "bus not started"); + break; + } + espp::Twai::Message m; + m.id = f.id; + m.extended = f.extended; + m.rtr = f.rtr; + m.dlc = f.dlc; + m.data = f.data; + if (twai->transmit(m, ec)) { + tx_count.fetch_add(1); + send_frame(can_bridge::kOk); + } else { + reply_error(ec, "transmit failed"); + } + break; + } + case can_bridge::kSetConfig: { + if (payload.size() < 6) { + reply_error(std::make_error_code(std::errc::invalid_argument), + "SET_CONFIG needs u32 baudrate + u8 mode + u8 reserved"); + break; + } + if (payload[4] > can_bridge::kModeListenOnly) { + reply_error(std::make_error_code(std::errc::invalid_argument), + "SET_CONFIG mode must be 0 (normal) or 1 (listen-only)"); + break; + } + { + std::lock_guard lock(bus_mutex); + if (twai) { + reply_error(std::make_error_code(std::errc::device_or_resource_busy), + "stop the bus before reconfiguring"); + break; + } + baudrate = sf::get_u32(payload); + mode = payload[4]; + } + send_frame(can_bridge::kOk); + send_status(); + break; + } + case can_bridge::kStart: + if (start_bus(ec)) { + send_frame(can_bridge::kOk); + send_status(); + } else { + reply_error(ec, "start failed"); + } + break; + case can_bridge::kStop: + stop_bus(); + send_frame(can_bridge::kOk); + send_status(); + break; + case can_bridge::kGetStatus: + send_status(); + break; + default: + reply_error(std::make_error_code(std::errc::not_supported), "unknown CAN bridge message"); + break; + } + }; + + // Vendor (WebUSB) and CDC (Web Serial) are independent byte streams, so each + // gets its OWN Dispatcher (one parser) — a frame split across reads on one + // transport must never be stitched onto bytes from the other. + espp::Dispatcher vendor_dispatcher, cdc_dispatcher; + vendor_dispatcher.register_module(can_bridge::kModuleId, handle_can_frame); + cdc_dispatcher.register_module(can_bridge::kModuleId, handle_can_frame); + + // --- USB RX plumbing: queue in the TinyUSB task, dispatch from a worker ----- + // transmit() can block up to its timeout, so it must not run in the TinyUSB + // callback context. + std::mutex rx_mutex; + std::condition_variable rx_cv; + // Tag each chunk with its source transport so the worker feeds it to that + // transport's own dispatcher (never stitching one stream's split frame onto + // the other's bytes). + std::deque>> rx_queue; + size_t rx_queued_bytes = 0; + bool rx_overflow = false; + static constexpr size_t kMaxQueuedRxBytes = 8 * sf::kMaxFrameSize; + auto enqueue_rx = [&](Transport source, std::span data) { + active_transport.store(source); // reply on the transport the host is using + { + std::lock_guard lock(rx_mutex); + if (rx_queued_bytes + data.size() > kMaxQueuedRxBytes) { + rx_queue.clear(); + rx_queued_bytes = 0; + rx_overflow = true; + } else { + rx_queue.emplace_back(source, std::vector(data.begin(), data.end())); + rx_queued_bytes += data.size(); + } + } + rx_cv.notify_one(); + }; + usb.set_vendor_receive_callback( + [&](std::span data) { enqueue_rx(Transport::Vendor, data); }); + usb.set_cdc_receive_callback( + [&](std::span data) { enqueue_rx(Transport::Cdc, data); }); + + std::error_code usb_ec; + const bool usb_ok = usb.initialize(usb_ec); + if (!usb_ok) + logger.error("Failed to initialize USB device: {} — no host transport available", + usb_ec.message()); + + espp::Task rx_task( + {.callback = [&](std::mutex &, std::condition_variable &) -> bool { + std::deque>> chunks; + bool overflowed = false; + { + std::unique_lock lock(rx_mutex); + rx_cv.wait_for(lock, 100ms, [&] { return !rx_queue.empty() || rx_overflow; }); + std::swap(chunks, rx_queue); + rx_queued_bytes = 0; + overflowed = rx_overflow; + rx_overflow = false; + } + if (overflowed) { + // Bytes were dropped: a frame straddling the gap would + // be stitched incorrectly, so resync both parsers. + vendor_dispatcher.reset(); + cdc_dispatcher.reset(); + return false; + } + for (const auto &[source, chunk] : chunks) + (source == Transport::Vendor ? vendor_dispatcher : cdc_dispatcher).feed(chunk); + return false; // keep running + }, + .task_config = {.name = "can_bridge_rx", .stack_size_bytes = 8192}}); + rx_task.start(); + + if (usb_ok) { + logger.info("CAN bridge ready. Connect the CAN console web app over WebUSB / Web Serial."); + logger.info("Bus starts stopped; the host sets baudrate/mode (SET_CONFIG) then START."); + } + + // Idle; all work happens in the TWAI receive task and the RX worker. + while (true) { + std::this_thread::sleep_for(1s); + } +} diff --git a/components/canopen/can_bridge_example/main/can_bridge_protocol.hpp b/components/canopen/can_bridge_example/main/can_bridge_protocol.hpp new file mode 100644 index 0000000000..95b9e59283 --- /dev/null +++ b/components/canopen/can_bridge_example/main/can_bridge_protocol.hpp @@ -0,0 +1,114 @@ +#pragma once + +// Wire protocol for the USB <-> CAN (TWAI) bridge example. +// +// Framed with the espp stream_frame v2 codec and routed by espp::Dispatcher on +// MODULE ID 5. The v2 frame has a DEDICATED module byte, so EVERY frame here — +// requests and replies alike — sets module = 5 and routes to dispatcher module +// 5 (this is NOT the retired v1 scheme where the module was derived from the +// type's high nibble). The 0x5X / 0xD_ values below are the `type` byte, not +// the module; the reply/event types (0xD_) additionally set the frame reply +// flag (build derives it from the type's high bit). The hosted CAN console web +// app speaks this exact protocol over WebUSB / Web Serial. +// +// A CAN frame is encoded as a compact payload: +// [id u32 LE][flags u8][dlc u8][data: dlc bytes] +// flags: bit0 = extended (29-bit id), bit1 = remote-transmission-request (RTR) +// so a frame payload is 6..14 bytes (dlc 0..8). + +#include +#include +#include +#include + +#include "stream_frame.hpp" + +namespace can_bridge { + +/// Dispatcher module id owned by the CAN bridge protocol. +static constexpr uint8_t kModuleId = 5; + +/// Host -> device (requests, high nibble 5). +enum : uint8_t { + kCanTx = 0x50, ///< transmit a CAN frame (payload: encoded CanFrame) + kSetConfig = 0x51, ///< set bus config (payload: u32 baudrate, u8 mode, u8 reserved) + kStart = 0x52, ///< bring the bus up with the current config (no payload) + kStop = 0x53, ///< take the bus down (no payload) + kGetStatus = 0x54, ///< request the current config + counters (no payload) +}; + +/// Device -> host (replies / events, high nibble D). +enum : uint8_t { + kCanRx = 0xD0, ///< a received CAN frame (payload: encoded CanFrame) + kOk = 0xD1, ///< ack for a request (payload: empty) + kError = 0xD2, ///< failure (payload: u32 code + UTF-8 message) + kStatus = 0xD3, ///< status reply (see StatusPayload below) +}; + +/// Bus mode requested by SET_CONFIG. +enum : uint8_t { + kModeNormal = 0, ///< actively participate (ACK frames) — "master" / send+receive + kModeListenOnly = 1, ///< passive monitor (never ACK/transmit) — "inspection" +}; + +/// CAN-frame flag bits used in the encoded payload. +enum : uint8_t { + kFlagExtended = 0x01, ///< 29-bit extended identifier + kFlagRtr = 0x02, ///< remote-transmission-request frame (no data) +}; + +/// A decoded classic-CAN (2.0) frame (mirrors espp::Twai::Message fields). +struct CanFrame { + uint32_t id{0}; + bool extended{false}; + bool rtr{false}; + uint8_t dlc{0}; + std::array data{}; +}; + +/// Encode a CanFrame to its wire payload: [id u32][flags u8][dlc u8][data]. +inline std::vector encode_frame(const CanFrame &f) { + std::vector p; + const uint8_t dlc = f.dlc > 8 ? 8 : f.dlc; + p.reserve(6 + dlc); + espp::stream_frame::put_u32(p, f.id); + uint8_t flags = 0; + if (f.extended) + flags |= kFlagExtended; + if (f.rtr) + flags |= kFlagRtr; + p.push_back(flags); + p.push_back(dlc); + // RTR frames carry no data; otherwise append dlc data bytes. + if (!f.rtr) + p.insert(p.end(), f.data.begin(), f.data.begin() + dlc); + return p; +} + +/// Decode a CAN-frame wire payload. Returns false if malformed (too short, dlc +/// out of range, or fewer data bytes than dlc for a non-RTR frame). +inline bool decode_frame(std::span payload, CanFrame &out) { + if (payload.size() < 6) + return false; + out.id = espp::stream_frame::get_u32(payload); + const uint8_t flags = payload[4]; + const uint8_t dlc = payload[5]; + out.extended = (flags & kFlagExtended) != 0; + out.rtr = (flags & kFlagRtr) != 0; + if (dlc > 8) + return false; + out.dlc = dlc; + out.data = {}; + if (!out.rtr) { + if (payload.size() < static_cast(6) + dlc) + return false; + for (uint8_t i = 0; i < dlc; ++i) + out.data[i] = payload[6 + i]; + } + return true; +} + +// Status reply payload layout (kStatus): +// [baudrate u32][mode u8][running u8][rx_count u32][tx_count u32][err_count u32] + +} // namespace can_bridge diff --git a/components/canopen/can_bridge_example/sdkconfig.defaults b/components/canopen/can_bridge_example/sdkconfig.defaults new file mode 100644 index 0000000000..fc50e6354e --- /dev/null +++ b/components/canopen/can_bridge_example/sdkconfig.defaults @@ -0,0 +1,31 @@ +# The USB vendor / WebUSB + CDC transports use the native USB-OTG peripheral, +# available on the ESP32-S3 (also S2 / P4) -- NOT the classic ESP32. Pin the +# target so a bare `idf.py build` does not fall back to esp32. +CONFIG_IDF_TARGET="esp32s3" + +# Common ESP-related +CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 + +# Keep the system console/logs on the built-in USB-Serial-JTAG peripheral so it +# stays separate from the native USB-OTG (vendor / CDC) interfaces created by +# espp::UsbDevice for the framed CAN protocol. (On an S3 devkit these are two +# distinct USB connectors.) +CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y + +# Enable the TinyUSB vendor + CDC class drivers. The vendor interface carries +# the framed protocol for WebUSB; the CDC interface carries the SAME framed +# protocol for Web Serial. HID count is set only so the usb_device component's +# HID code paths compile (no HID interface is instantiated here). esp_tinyusb +# gates each class behind these counts. +CONFIG_TINYUSB_VENDOR_COUNT=1 +CONFIG_TINYUSB_CDC_ENABLED=y +CONFIG_TINYUSB_CDC_COUNT=1 +CONFIG_TINYUSB_HID_COUNT=1 + +# Give the vendor RX/TX FIFOs headroom for CAN-RX frame bursts on a busy bus. +CONFIG_TINYUSB_VENDOR_RX_BUFSIZE=2048 +CONFIG_TINYUSB_VENDOR_TX_BUFSIZE=2048 + +# C++ +CONFIG_COMPILER_CXX_EXCEPTIONS=y diff --git a/components/canopen/idf_component.yml b/components/canopen/idf_component.yml index 6d9bc17a94..b0d7215708 100644 --- a/components/canopen/idf_component.yml +++ b/components/canopen/idf_component.yml @@ -8,6 +8,7 @@ maintainers: documentation: "https://esp-cpp.github.io/espp/buses/canopen.html" examples: - path: example + - path: can_bridge_example tags: - cpp - Component diff --git a/components/canopen/web/can_console.html b/components/canopen/web/can_console.html new file mode 100644 index 0000000000..b93917d114 --- /dev/null +++ b/components/canopen/web/can_console.html @@ -0,0 +1,1157 @@ + + + + + + espp CAN Bridge Console (WebUSB / Web Serial) + + + + + +
+
+

espp CAN Bridge Console (WebUSB / Web Serial)

+ Disconnected +
+ +
+ This browser supports neither WebUSB nor Web Serial. Use a Chromium-based + browser (Chrome / Edge / Opera) on a secure origin (https, localhost or file://). +
+ + +
+

Device

+
+ + + + +
+

Not connected. WebUSB default filter: VID 0x1209 / PID 0x0d32 (espp default); the vendor (0xFF) interface is discovered from the descriptors at runtime.

+
+ + +
+

Bus configuration

+
+
+ Baud rate + +
+
+ Mode + +
+
+ + + +
+
+ Bus: unknown + Baud: - + Mode: - + RX: 0 + TX: 0 + Err: 0 +
+
+ + +
+

Send frame

+
+
+ ID (hex) + +
+ + +
+ DLC (blank = auto) + +
+
+ Data (hex bytes, e.g. "DE AD BE EF") + +
+ +
+ +
+ + +
+

Monitor

+
+ + + + +
+ 0 frames (0 dropped) +
+
+ + + + + + + +
TimeDirIDTypeDLCDataASCII
+
+ +
+ + +
+

Log

+
+ + +
+
+
+ +
+ Speaks the espp stream_frame v2 protocol (magic "OT" + flags + + module + type + len + payload + CRC-32) carrying the USB<->CAN bridge + message set on module 5. Works over WebUSB (vendor 0xFF interface) or Web + Serial; frames for other modules/types on the stream are ignored so the + bridge can multiplex other protocols. +
+
+ + + + diff --git a/components/usb_device/README.md b/components/usb_device/README.md index 691053d12a..cc62be534f 100644 --- a/components/usb_device/README.md +++ b/components/usb_device/README.md @@ -94,8 +94,13 @@ Key methods: - `bool initialize(std::error_code &ec)` — build descriptors from the enabled functions, check the endpoint budget, install the TinyUSB driver. -- `bool write_cdc(...)` / `bool write_vendor(...)` — queue + non-blocking flush on - the respective interface. +- `bool write_cdc(...)` / `bool write_vendor(...)` — send bytes on the respective + interface with all-or-nothing backpressure. A frame that fits in the TX FIFO is + written atomically: the call bounded-waits (250 ms) for room for the whole + frame, then enqueues it in one write, so a timeout/disconnect never leaves a + truncated prefix on the wire (returns `false` and drops the frame instead). In + TinyUSB-callback context it fails fast if the frame does not already fit. + Frames larger than the FIFO are streamed and are not atomic. - `bool write_hid_report(uint8_t report_id, std::span report, ...)` — send a HID input report on the HID interrupt IN endpoint. - `void set_cdc_receive_callback(...)` / `void set_vendor_receive_callback(...)`. diff --git a/components/usb_device/include/usb_device.hpp b/components/usb_device/include/usb_device.hpp index fdcf804a17..100154a532 100644 --- a/components/usb_device/include/usb_device.hpp +++ b/components/usb_device/include/usb_device.hpp @@ -188,8 +188,23 @@ class UsbDevice : public BaseComponent { /** * @brief Queue bytes for transmission over the CDC function and flush. * @param data Bytes to send. - * @param[out] ec Set on failure (e.g. CDC not enabled / not initialized). + * @param[out] ec Set on failure (e.g. CDC not enabled / not initialized, or + * the TX FIFO could not accept all bytes - see note below). * @return true if all bytes were queued, false otherwise. + * @note Same backpressure contract as write_vendor(). A frame that fits in the + * TX FIFO (CONFIG_TINYUSB_CDC_TX_BUFSIZE) is written ALL-OR-NOTHING: the + * call sleep-waits (bounded, 250 ms) for room for the WHOLE frame and + * then enqueues it in a single write, so a drain-timeout or a mid-write + * disconnect returns false WITHOUT leaving a truncated prefix on the wire + * (a partial frame would poison the host-side framing parser). When + * called from TinyUSB-callback context (e.g. inside a receive callback, + * which runs on the TinyUSB task) the drain can never happen while this + * call blocks, so it fails fast with `no_buffer_space` if the whole frame + * does not ALREADY fit - again without enqueueing anything. A frame + * LARGER than the FIFO cannot be atomic and is streamed across drains + * (a mid-stream timeout may leave a prefix on the wire); keep framed + * payloads within the FIFO, or send large replies from your own task + * rather than a receive callback, for atomic writes. */ bool write_cdc(std::span data, std::error_code &ec); @@ -202,18 +217,20 @@ class UsbDevice : public BaseComponent { * @param[out] ec Set on failure (e.g. vendor not enabled / not initialized, * or the TX FIFO could not accept all bytes - see note below). * @return true if all bytes were queued, false otherwise. - * @note If the TX FIFO (CONFIG_TINYUSB_VENDOR_TX_BUFSIZE) fills mid-write, - * this call sleep-waits (bounded, 250 ms) for the TinyUSB task to - * drain it - EXCEPT when called from TinyUSB-callback context (e.g. - * from inside a receive callback, which runs on the TinyUSB task): - * there the drain can never happen while this call blocks, so writes - * are ALL-OR-NOTHING - if the whole frame does not fit in the FIFO up - * front, the call fails fast with `no_buffer_space` WITHOUT enqueueing - * any bytes (a partially-enqueued frame would poison the byte stream - * for the host). To reliably send frames larger than the TX FIFO in - * response to received data, queue the work to your own task rather - * than writing directly from the receive callback (or size the FIFO - * to hold a full frame). + * @note A frame that fits in the TX FIFO (CONFIG_TINYUSB_VENDOR_TX_BUFSIZE) is + * written ALL-OR-NOTHING: the call sleep-waits (bounded, 250 ms) for room + * for the WHOLE frame and then enqueues it in a single write, so a + * drain-timeout or a mid-write unmount returns false WITHOUT leaving a + * truncated prefix on the wire (a partial frame would poison the + * host-side framing parser). When called from TinyUSB-callback context + * (e.g. inside a receive callback, which runs on the TinyUSB task) the + * drain can never happen while this call blocks, so it fails fast with + * `no_buffer_space` if the whole frame does not ALREADY fit - again + * without enqueueing anything. A frame LARGER than the FIFO cannot be + * atomic and is streamed across drains (a mid-stream timeout may leave a + * prefix on the wire); keep framed payloads within the FIFO, or send + * large replies from your own task rather than a receive callback, for + * atomic writes. */ bool write_vendor(std::span data, std::error_code &ec); diff --git a/components/usb_device/src/usb_device.cpp b/components/usb_device/src/usb_device.cpp index a238eaf711..1887cd2bea 100644 --- a/components/usb_device/src/usb_device.cpp +++ b/components/usb_device/src/usb_device.cpp @@ -27,6 +27,15 @@ std::atomic s_device{nullptr}; // The CDC port this component uses. A single dedicated CDC-ACM interface. constexpr tinyusb_cdcacm_itf_t kCdcPort = TINYUSB_CDC_ACM_0; +// Backpressure tuning shared by write_cdc() and write_vendor() so the two TX +// paths stay consistent. kUsbWriteTimeoutTicks bounds how long a blocking write +// sleep-waits for the host to drain a full TX FIFO before dropping the frame. +// kUsbWriteDrainPollTicks is the poll interval while waiting - never less than +// one tick (pdMS_TO_TICKS(1) is 0 when the tick rate is below 1 kHz, and +// vTaskDelay(0) would not block at all). +constexpr TickType_t kUsbWriteTimeoutTicks = pdMS_TO_TICKS(250); +constexpr TickType_t kUsbWriteDrainPollTicks = pdMS_TO_TICKS(1) > 0 ? pdMS_TO_TICKS(1) : 1; + // ESP32-S3 / -S2 USB-OTG (DWC2, full-speed) endpoint budget: besides the control // endpoint EP0, there are ~5 usable data IN endpoints and ~5 usable data OUT // endpoints. See the README endpoint-budget table for which class combinations @@ -862,27 +871,102 @@ bool UsbDevice::initialize(std::error_code &ec) { bool UsbDevice::write_cdc(std::span data, std::error_code &ec) { ec.clear(); +#if (CFG_TUD_CDC > 0) if (!initialized_ || !config_.cdc) { ec = std::make_error_code(std::errc::not_connected); return false; } + // A frame is written ALL-OR-NOTHING when it fits in the TX FIFO + // (CONFIG_TINYUSB_CDC_TX_BUFSIZE): we wait (bounded) for room for the WHOLE + // frame and only then enqueue it in a SINGLE write, so a drain-timeout or a + // mid-write disconnect can never leave a truncated prefix on the wire (a + // partial frame is useless to the host - its parser discards it on the + // length/CRC check). Uses the raw tud_cdc_n_* API (not the esp_tinyusb TX + // ringbuffer) so the whole frame can be sized up front, exactly as + // write_vendor() uses tud_vendor_*. + // + // In TinyUSB-callback context (e.g. from inside a receive callback, which is + // dispatched on the TinyUSB task) we cannot wait for a drain: tud_task() is + // below us on this very stack, so the TX-complete events that refill the + // endpoint cannot be processed while we sleep. There we fail fast if the whole + // frame does not ALREADY fit, again without enqueueing anything. + // + // A frame LARGER than the whole TX FIFO cannot be enqueued atomically, so it + // is streamed across drains and is NOT all-or-nothing (a mid-stream timeout + // may leave a prefix on the wire). Keep framed payloads within the FIFO for + // atomic writes. + const bool in_tinyusb_task = on_tinyusb_task(); + const TickType_t start_tick = xTaskGetTickCount(); + + if (data.size() <= CFG_TUD_CDC_TX_BUFSIZE) { + // Atomic path: wait until the whole frame fits, then write it in one shot. + while (tud_cdc_n_write_available(kCdcPort) < data.size()) { + if (in_tinyusb_task) { + logger_.warn_rate_limited("CDC TX FIFO cannot hold the whole {}-byte frame in " + "TinyUSB-callback context (cannot wait for a drain here), " + "dropping it - send from a separate task instead", + data.size()); + ec = std::make_error_code(std::errc::no_buffer_space); + return false; + } + // Host closing the port (DTR cleared) is a different condition from + // backpressure: report not_connected so callers do not treat it like a + // full FIFO. + if (!tud_cdc_n_connected(kCdcPort)) { + logger_.warn_rate_limited("CDC host disconnected before a {}-byte frame could be sent", + data.size()); + ec = std::make_error_code(std::errc::not_connected); + return false; + } + // Unsigned tick subtraction stays correct across tick-count wraparound. + if ((xTaskGetTickCount() - start_tick) >= kUsbWriteTimeoutTicks) { + logger_.warn_rate_limited("CDC TX FIFO full, dropping a {}-byte frame", data.size()); + ec = std::make_error_code(std::errc::no_buffer_space); + return false; + } + vTaskDelay(kUsbWriteDrainPollTicks); + } + // Room for the whole frame is guaranteed, so this single write takes all of + // it - no prefix/truncation is possible. + tud_cdc_n_write(kCdcPort, data.data(), data.size()); + tud_cdc_n_write_flush(kCdcPort); + return true; + } + + // Streaming path: frame larger than the FIFO (NOT atomic - see note above). size_t offset = 0; while (offset < data.size()) { - size_t queued = - tinyusb_cdcacm_write_queue(kCdcPort, data.data() + offset, data.size() - offset); + uint32_t queued = tud_cdc_n_write(kCdcPort, data.data() + offset, data.size() - offset); + tud_cdc_n_write_flush(kCdcPort); + offset += queued; if (queued == 0) { - tinyusb_cdcacm_write_flush(kCdcPort, 0); - queued = tinyusb_cdcacm_write_queue(kCdcPort, data.data() + offset, data.size() - offset); - if (queued == 0) { + if (in_tinyusb_task) { + logger_.warn_rate_limited("CDC TX buffer full in TinyUSB-callback context (cannot wait " + "for a drain here), dropping {} bytes", + data.size() - offset); + ec = std::make_error_code(std::errc::no_buffer_space); + break; + } + if (!tud_cdc_n_connected(kCdcPort)) { + logger_.warn_rate_limited("CDC host disconnected mid-write, dropping {} bytes", + data.size() - offset); + ec = std::make_error_code(std::errc::not_connected); + break; + } + if ((xTaskGetTickCount() - start_tick) >= kUsbWriteTimeoutTicks) { logger_.warn_rate_limited("CDC TX buffer full, dropping {} bytes", data.size() - offset); ec = std::make_error_code(std::errc::no_buffer_space); break; } + vTaskDelay(kUsbWriteDrainPollTicks); } - offset += queued; - tinyusb_cdcacm_write_flush(kCdcPort, 0); } return offset == data.size(); +#else + (void)data; + ec = std::make_error_code(std::errc::function_not_supported); + return false; +#endif } bool UsbDevice::write_cdc(std::span data) { @@ -897,38 +981,66 @@ bool UsbDevice::write_vendor(std::span data, std::error_code &ec) ec = std::make_error_code(std::errc::not_connected); return false; } - size_t offset = 0; - // The vendor TX FIFO (CONFIG_TINYUSB_VENDOR_TX_BUFSIZE, 64 bytes by default) - // is commonly SMALLER than one protocol frame, so a full FIFO is the normal - // mid-write condition, not an error: wait for the USB task to drain it - // instead of truncating (a partial frame is useless to the host - its - // parser discards it on the length/CRC check). Bounded so an unplugged or - // non-reading host cannot wedge the caller. + // Same all-or-nothing contract as write_cdc(): a frame that fits in the vendor + // TX FIFO (CONFIG_TINYUSB_VENDOR_TX_BUFSIZE) is written atomically - wait + // (bounded) for room for the WHOLE frame, then enqueue it in a SINGLE write, + // so a drain-timeout or a mid-write unmount can never leave a truncated prefix + // on the wire (a partial frame is useless to the host - its parser discards it + // on the length/CRC check). // - // EXCEPTION: when called from TinyUSB-callback context (e.g. from inside a - // receive callback, which is dispatched on the TinyUSB task), tud_task() is - // below us on this very stack, so the TX-complete events that refill the - // endpoint from the FIFO cannot be processed while we sleep - waiting would - // just burn the full timeout and truncate anyway. Writes from this context - // are therefore ALL-OR-NOTHING: check up front that the whole frame fits in - // the FIFO and fail fast WITHOUT enqueueing anything if it does not - a - // partially-enqueued frame would be transmitted and poison the byte stream - // for the host-side parser. Callers needing replies larger than the FIFO - // should queue the work to their own task (see the docs on write_vendor()). + // In TinyUSB-callback context (e.g. from inside a receive callback, dispatched + // on the TinyUSB task) we cannot wait for a drain: tud_task() is below us on + // this very stack, so the TX-complete events that refill the endpoint cannot + // run while we sleep. There we fail fast if the whole frame does not ALREADY + // fit, again without enqueueing anything. + // + // A frame LARGER than the whole TX FIFO cannot be enqueued atomically, so it + // is streamed across drains and is NOT all-or-nothing (a mid-stream timeout + // may leave a prefix on the wire). Keep framed payloads within the FIFO for + // atomic writes. const bool in_tinyusb_task = on_tinyusb_task(); - if (in_tinyusb_task && tud_vendor_write_available() < data.size()) { - logger_.warn_rate_limited("Vendor TX FIFO cannot hold the whole {}-byte frame in " - "TinyUSB-callback context (cannot wait for a drain here), dropping " - "it - send large frames from a separate task instead", - data.size()); - ec = std::make_error_code(std::errc::no_buffer_space); - return false; - } static constexpr TickType_t kVendorWriteTimeoutTicks = pdMS_TO_TICKS(250); // Poll at ~1 ms, but never less than one tick (pdMS_TO_TICKS(1) is 0 when // the tick rate is below 1 kHz, and vTaskDelay(0) would not block at all). static constexpr TickType_t kVendorDrainPollTicks = pdMS_TO_TICKS(1) > 0 ? pdMS_TO_TICKS(1) : 1; const TickType_t start_tick = xTaskGetTickCount(); + + if (data.size() <= CFG_TUD_VENDOR_TX_BUFSIZE) { + // Atomic path: wait until the whole frame fits, then write it in one shot. + while (tud_vendor_write_available() < data.size()) { + if (in_tinyusb_task) { + logger_.warn_rate_limited("Vendor TX FIFO cannot hold the whole {}-byte frame in " + "TinyUSB-callback context (cannot wait for a drain here), " + "dropping it - send from a separate task instead", + data.size()); + ec = std::make_error_code(std::errc::no_buffer_space); + return false; + } + // Unplug/disconnect is a different condition from backpressure: report + // not_connected so callers do not treat an unmount like a full FIFO. + if (!tud_vendor_mounted()) { + logger_.warn_rate_limited("Vendor device unmounted before a {}-byte frame could be sent", + data.size()); + ec = std::make_error_code(std::errc::not_connected); + return false; + } + // Unsigned tick subtraction stays correct across tick-count wraparound. + if ((xTaskGetTickCount() - start_tick) >= kVendorWriteTimeoutTicks) { + logger_.warn_rate_limited("Vendor TX FIFO full, dropping a {}-byte frame", data.size()); + ec = std::make_error_code(std::errc::no_buffer_space); + return false; + } + vTaskDelay(kVendorDrainPollTicks); + } + // Room for the whole frame is guaranteed, so this single write takes all of + // it - no prefix/truncation is possible. + tud_vendor_write(data.data(), data.size()); + tud_vendor_write_flush(); + return true; + } + + // Streaming path: frame larger than the FIFO (NOT atomic - see note above). + size_t offset = 0; while (offset < data.size()) { uint32_t queued = tud_vendor_write(data.data() + offset, data.size() - offset); tud_vendor_write_flush(); @@ -936,21 +1048,17 @@ bool UsbDevice::write_vendor(std::span data, std::error_code &ec) if (queued == 0) { if (in_tinyusb_task) { logger_.warn_rate_limited("Vendor TX buffer full in TinyUSB-callback context (cannot wait " - "for a drain here), dropping {} bytes - send large frames from a " - "separate task instead", + "for a drain here), dropping {} bytes", data.size() - offset); ec = std::make_error_code(std::errc::no_buffer_space); break; } - // Unplug/disconnect is a different condition from backpressure: report - // not_connected so callers do not treat an unmount like a full FIFO. if (!tud_vendor_mounted()) { logger_.warn_rate_limited("Vendor device unmounted mid-write, dropping {} bytes", data.size() - offset); ec = std::make_error_code(std::errc::not_connected); break; } - // Unsigned tick subtraction stays correct across tick-count wraparound. if ((xTaskGetTickCount() - start_tick) >= kVendorWriteTimeoutTicks) { logger_.warn_rate_limited("Vendor TX buffer full, dropping {} bytes", data.size() - offset); ec = std::make_error_code(std::errc::no_buffer_space); diff --git a/doc/en/buses/canopen.rst b/doc/en/buses/canopen.rst index f207ef278a..1896207dd0 100644 --- a/doc/en/buses/canopen.rst +++ b/doc/en/buses/canopen.rst @@ -35,6 +35,17 @@ performing SDO transfers; with `Twai` this is automatically the case since its canopen_example +USB to CAN bridge +----------------- + +The ``can_bridge_example`` (``components/canopen/can_bridge_example``) turns an +ESP32-S3 into a WebUSB / Web Serial CAN interface: it bridges the ``Twai`` +(CAN 2.0) controller to the host over USB using the ``stream_frame`` framing and +an :doc:`../dispatcher/index` (module id 5), so the hosted +`CAN console `_ web app can +send frames (as a bus master) and inspect the bus (streaming every received +frame, optionally in passive listen-only mode) directly from a Chromium browser. + .. ---------------------------- API Reference ---------------------------------- API Reference