diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1a805be16..f39e533dc 100755 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -79,6 +79,9 @@ jobs: target: esp32 - path: 'components/mcp266/example' target: esp32 + - path: 'components/mcp266/webapp_example' + target: esp32s3 + command: 'IDF_COMPONENT_MANAGER=0 idf.py build' - path: 'components/bdc_driver/example' target: esp32s3 - path: 'components/binary-log/example' diff --git a/components/mcp266/idf_component.yml b/components/mcp266/idf_component.yml index 3a61c31c3..cf9716863 100644 --- a/components/mcp266/idf_component.yml +++ b/components/mcp266/idf_component.yml @@ -8,6 +8,7 @@ maintainers: documentation: "https://esp-cpp.github.io/espp/motorcontrol/mcp266.html" examples: - path: example + - path: webapp_example tags: - cpp - Component diff --git a/components/mcp266/web/mcp266_console.html b/components/mcp266/web/mcp266_console.html new file mode 100644 index 000000000..77291f2f8 --- /dev/null +++ b/components/mcp266/web/mcp266_console.html @@ -0,0 +1,477 @@ + + + + + + espp MCP266 Console (WebUSB / Web Serial) + + + + + +
+
+

espp MCP266 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. Default WebUSB filter: VID 0x1209 / PID 0x0d32 (espp default).

+
+ Device type: - + Name: - + Battery: - + Temp: - +
+ + +
+
+ + +
+ + +
+

Log

+
+
+ + +
+ + + + diff --git a/components/mcp266/webapp_example/CMakeLists.txt b/components/mcp266/webapp_example/CMakeLists.txt new file mode 100644 index 000000000..77456485f --- /dev/null +++ b/components/mcp266/webapp_example/CMakeLists.txt @@ -0,0 +1,55 @@ +# 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) + +# This example needs the managed `espressif/esp_tinyusb` component (required by +# usb_device). It supports two build modes: +# +# * DEFAULT (component manager ON) - how an end user builds it from the +# component registry: the manager fetches esp_tinyusb (and its tinyusb +# dependency) and the espp/* dependencies from the registry. EXTRA_COMPONENT_DIRS +# is narrowed to just the components this example uses so the manager does not +# scan every espp manifest (some board components declare target-specific +# constraints that would fail on esp32s3). +# +# * MANAGER OFF (IDF_COMPONENT_MANAGER=0) - used by CI so the build does not need +# the (as-yet unpublished) espp/* components in the registry: every espp +# dependency resolves locally from EXTRA_COMPONENT_DIRS, and esp_tinyusb / +# tinyusb come from the vendored git submodules under external/ (esp_tinyusb +# ships inside the espressif/esp-usb monorepo, so its component subdir is added). +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +set(EXTRA_COMPONENT_DIRS + "../../../components/base_component" + "../../../components/canopen" + "../../../components/dispatcher" + "../../../components/format" + "../../../components/logger" + "../../../components/mcp266" + "../../../components/stream_frame" + "../../../components/task" + "../../../components/twai" + "../../../components/usb_device" +) + +# With the component manager disabled, esp_tinyusb/tinyusb are not fetched from +# the registry; add the vendored submodule copies to the component search path. +# esp_tinyusb's own CMakeLists adds `tinyusb` to its REQUIRES when the manager is +# off, so both component directories must be discoverable here. +if(DEFINED ENV{IDF_COMPONENT_MANAGER} AND "$ENV{IDF_COMPONENT_MANAGER}" STREQUAL "0") + list(APPEND EXTRA_COMPONENT_DIRS + "../../../external/esp-usb/device/esp_tinyusb" + "../../../external/tinyusb" + ) +endif() + +set( + COMPONENTS + "main esptool_py base_component canopen dispatcher format logger mcp266 stream_frame task twai usb_device esp_tinyusb" + CACHE STRING + "List of components to include" + ) + +project(mcp266_webapp_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/mcp266/webapp_example/README.md b/components/mcp266/webapp_example/README.md new file mode 100644 index 000000000..ddb2bb00b --- /dev/null +++ b/components/mcp266/webapp_example/README.md @@ -0,0 +1,70 @@ +# MCP266 Web Console Example + +Turns an ESP32-S3 into a **WebUSB / Web Serial front-end** for a Basicmicro +MCP266 motor controller: the hosted +[MCP266 console web app](https://esp-cpp.github.io/espp/apps/mcp266_console.html) +connects over native USB and can + +- **configure** each axis' position loop (clamp + fallback P gain) and CiA 402 + software limits, and clear faults / e-stop, +- **command** profile-position moves (target, velocity, accel, decel), and +- **view** live per-axis status — position, velocity, DS402 state, target-reached + — plus device telemetry (battery voltage, temperature). + +Unlike the [CAN bridge](../../canopen/can_bridge_example) (which forwards raw CAN +and runs CANopen in the browser), this example runs the `espp::Mcp266` driver +**on the device** and exposes a small high-level protocol (see +`main/mcp266_protocol.hpp`, dispatcher **module id 6**), so the web app needs no +CANopen/DS402 knowledge. Both the vendor (WebUSB) and CDC (Web Serial) interfaces +carry the same protocol; the system console/logs stay on the built-in +USB-Serial-JTAG. + +## Wiring & configuration + +The ESP32-S3 is the CANopen **master** of the MCP266 node. Connect the TWAI +TX/RX GPIOs to a 3.3 V CAN transceiver (e.g. SN65HVD230) on a 120 Ω-terminated +bus, at the baudrate configured on the MCP266 in Basicmicro Motion Studio. + +Defaults (change in `main/mcp266_webapp_example.cpp`): + +| Setting | Value | +|---------|-------| +| TWAI TX | GPIO 17 | +| TWAI RX | GPIO 16 | +| CAN baudrate | 1000000 | +| MCP266 node id | 10 | + +## Protocol (module 6) + +Framed with `stream_frame` and routed by `espp::Dispatcher`. Requests use type +high-nibble 6; replies/events use high-nibble E (reply flag set). + +| Type | Dir | Meaning | +|------|-----|---------| +| `0x60` START | H→D | NMT-start the node + clear faults | +| `0x61` RESET_FAULTS | H→D | clear latched CiA 402 faults (both axes) | +| `0x62` RESET_ESTOP | H→D | attempt an e-stop reset | +| `0x63` CONFIGURE_POSITION_LOOP | H→D | `[axis u8][min i32][max i32][fallback_p i32]` | +| `0x64` SET_POSITION_LIMITS | H→D | CiA 402 software limits: `[axis u8][min i32][max i32]` | +| `0x65` MOVE_TO_POSITION | H→D | `[axis u8][target i32][vel u32][accel u32][decel u32]` | +| `0x66` DRIVE_SPEED | H→D | `[axis u8][qpps i32]` (inert on tested firmware) | +| `0x67` DRIVE_DUTY | H→D | `[axis u8][duty i16]` (inert on tested firmware) | +| `0x68` GET_STATUS | H→D | request one STATUS snapshot | +| `0x69` SET_STATUS_STREAM | H→D | `[enable u8][period_ms u16]` | +| `0x6A` GET_DEVICE_INFO | H→D | request DEVICE_INFO | +| `0xE0` STATUS | D→H | per-axis `[pos i32][vel i32][statusword u16]` ×2, then `[battery_dV u16][temp_dC u16][flags u8]` | +| `0xE1` OK | D→H | `[request_type u8]` | +| `0xE2` ERROR | D→H | `[request_type u8][code u32][utf8 message]` | +| `0xE3` DEVICE_INFO | D→H | `[device_type u32][utf8 name]` | + +`axis` is `0` = M1, `1` = M2. + +## Build & flash + +``` +idf.py set-target esp32s3 +idf.py build flash monitor +``` + +Then open the MCP266 console web app and Connect (WebUSB or Web Serial). Click +**Start node**, tick **Live status**, then configure a loop and command a move. diff --git a/components/mcp266/webapp_example/main/CMakeLists.txt b/components/mcp266/webapp_example/main/CMakeLists.txt new file mode 100644 index 000000000..16f46a48d --- /dev/null +++ b/components/mcp266/webapp_example/main/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + SRC_DIRS "." + INCLUDE_DIRS "." + REQUIRES mcp266 canopen twai usb_device dispatcher stream_frame task logger +) diff --git a/components/mcp266/webapp_example/main/mcp266_protocol.hpp b/components/mcp266/webapp_example/main/mcp266_protocol.hpp new file mode 100644 index 000000000..db56fc74b --- /dev/null +++ b/components/mcp266/webapp_example/main/mcp266_protocol.hpp @@ -0,0 +1,70 @@ +#pragma once + +// Wire protocol for the USB <-> MCP266 web console example. +// +// Unlike the CAN bridge (which forwards raw CAN frames and runs CANopen in the +// browser), this example runs the espp::Mcp266 driver ON the device and exposes +// a small, HIGH-LEVEL command protocol: the browser sends "configure axis", +// "move to position", "get status" and the firmware translates each to Mcp266 +// calls over CANopen. So the web app needs no CANopen/DS402 knowledge. +// +// Framed with the espp stream_frame v2 codec and routed by an espp::Dispatcher +// on MODULE ID 6. Every frame sets module = 6. The `type` byte's high nibble is +// 6 for host->device requests and E for device->host replies/events; the reply +// types (0xE_) additionally set the frame reply flag (build_frame derives it +// from the type's high bit). Both the vendor (WebUSB) and CDC (Web Serial) +// interfaces carry this same protocol. +// +// Axis selector byte: 0 = M1, 1 = M2. + +#include + +namespace mcp266_protocol { + +/// Dispatcher module id owned by the MCP266 console protocol. +static constexpr uint8_t kModuleId = 6; + +/// Axis selector used in request payloads (matches espp::Mcp266::Axis order). +enum : uint8_t { + kAxisM1 = 0, + kAxisM2 = 1, +}; + +/// Host -> device (requests, high nibble 6). +enum : uint8_t { + kStart = 0x60, ///< NMT-start the node + clear latched faults (no payload) + kResetFaults = 0x61, ///< clear latched CiA 402 faults on both axes (no payload) + kResetEstop = 0x62, ///< attempt an e-stop reset (no payload) + kConfigurePositionLoop = 0x63, ///< [axis u8][min i32][max i32][fallback_p i32] + kSetPositionLimits = 0x64, ///< CiA 402 software limits: [axis u8][min i32][max i32] + kMoveToPosition = 0x65, ///< [axis u8][target i32][vel u32][accel u32][decel u32] + kDriveSpeed = 0x66, ///< [axis u8][qpps i32] (inert on tested firmware) + kDriveDuty = 0x67, ///< [axis u8][duty i16] (inert on tested firmware) + kGetStatus = 0x68, ///< request one STATUS snapshot (no payload) + kSetStatusStream = 0x69, ///< [enable u8][period_ms u16] periodic STATUS streaming. + ///< period_ms is clamped to [50, 10000] on the device (0 = + ///< default 200 ms); each snapshot issues eight blocking SDO + ///< reads, so smaller periods are rejected to protect the bus. + kGetDeviceInfo = 0x6A, ///< request DEVICE_INFO (no payload) +}; + +/// Device -> host (replies / events, high nibble E => reply flag set). +enum : uint8_t { + kStatus = 0xE0, ///< status snapshot (see StatusPayload layout below) + kOk = 0xE1, ///< ack for a request: [request_type u8] + kError = 0xE2, ///< failure: [request_type u8][code u32][utf8 message] + kDeviceInfo = 0xE3, ///< [device_type u32][utf8 name] +}; + +/// STATUS payload layout (all multi-byte fields little-endian), 25 bytes: +/// per axis M1 then M2: +/// [position i32][velocity i32][statusword u16] (10 bytes each) +/// then device-level: +/// [battery_decivolts u16] (tenths of a volt) +/// [temp_decidegrees u16] (tenths of a degree C) +/// [flags u8] (bit0 = node responded to the last poll) +static constexpr uint8_t kStatusFlagOnline = 0x01; +static constexpr uint8_t kAxisStatusSize = 10; ///< i32 + i32 + u16 +static constexpr uint8_t kStatusPayloadSize = 2 * kAxisStatusSize + 2 + 2 + 1; // = 25 + +} // namespace mcp266_protocol diff --git a/components/mcp266/webapp_example/main/mcp266_webapp_example.cpp b/components/mcp266/webapp_example/main/mcp266_webapp_example.cpp new file mode 100644 index 000000000..67e6aa548 --- /dev/null +++ b/components/mcp266/webapp_example/main/mcp266_webapp_example.cpp @@ -0,0 +1,427 @@ +// USB <-> MCP266 web console example. +// +// Runs the espp::Mcp266 driver on an ESP32-S3 and exposes it to a browser over +// USB: the hosted MCP266 console web app connects on the vendor (WebUSB) OR CDC +// (Web Serial) interface and can configure the position loops, command moves, +// and view live per-axis status (position / velocity / DS402 state) plus device +// telemetry (battery, temperature). All CANopen/DS402 work happens on the +// device behind a small high-level protocol (see mcp266_protocol.hpp); the web +// app needs no CANopen knowledge. +// +// Wiring: the ESP32-S3 is the CANopen MASTER of the MCP266 node, so connect the +// TWAI TX/RX GPIOs to a 3.3 V CAN transceiver on a terminated bus at the +// baudrate configured on the MCP266 (Basicmicro Motion Studio). Set kNodeId to +// the MCP266's configured CANopen node id. The system console/logs go to the +// separate built-in USB-Serial-JTAG. + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "canopen_client.hpp" +#include "dispatcher.hpp" +#include "logger.hpp" +#include "mcp266.hpp" +#include "stream_frame.hpp" +#include "task.hpp" +#include "twai.hpp" +#include "usb_device.hpp" + +#include "mcp266_protocol.hpp" + +using namespace std::chrono_literals; +namespace sf = espp::stream_frame; +namespace proto = mcp266_protocol; +using Axis = espp::Mcp266::Axis; + +// --- device configuration (change to match your board / MCP266) -------------- +static constexpr int kCanTxGpio = 17; +static constexpr int kCanRxGpio = 16; +static constexpr uint32_t kCanBaudrate = 1000000; +static constexpr uint8_t kNodeId = 10; // the MCP266's CANopen node id (Motion Studio) + +// --- little-endian payload helpers ------------------------------------------- +static uint32_t rd_u32(std::span p, size_t off) { + return static_cast(p[off]) | (static_cast(p[off + 1]) << 8) | + (static_cast(p[off + 2]) << 16) | (static_cast(p[off + 3]) << 24); +} +static int32_t rd_i32(std::span p, size_t off) { + return static_cast(rd_u32(p, off)); +} +static int16_t rd_i16(std::span p, size_t off) { + return static_cast(static_cast(p[off]) | + (static_cast(p[off + 1]) << 8)); +} +static uint16_t rd_u16(std::span p, size_t off) { + return static_cast(static_cast(p[off]) | + (static_cast(p[off + 1]) << 8)); +} +static void put_u16(std::vector &v, uint16_t x) { + v.push_back(x & 0xFF); + v.push_back((x >> 8) & 0xFF); +} +static void put_i32(std::vector &v, int32_t x) { + const auto u = static_cast(x); + v.push_back(u & 0xFF); + v.push_back((u >> 8) & 0xFF); + v.push_back((u >> 16) & 0xFF); + v.push_back((u >> 24) & 0xFF); +} +static Axis axis_of(uint8_t b) { return b == proto::kAxisM2 ? Axis::M2 : Axis::M1; } + +extern "C" void app_main(void) { + espp::Logger logger({.tag = "MCP266 Console", .level = espp::Logger::Verbosity::INFO}); + logger.info("Starting USB<->MCP266 console example (node id {})", kNodeId); + + // --- CAN transport + CANopen client + MCP266 driver ------------------------ + // The Twai receive task feeds process_frame(); the MCP266 SDO transactions run + // on OTHER tasks (the USB RX worker + the status streamer), satisfying + // CanopenClient's "pump RX from a different task" contract. + static espp::CanopenClient *client_ptr = nullptr; + espp::Twai twai({ + .tx_gpio = kCanTxGpio, + .rx_gpio = kCanRxGpio, + .baudrate = kCanBaudrate, + .mode = espp::Twai::Mode::NORMAL, + .tx_queue_depth = 10, + .on_receive = + [](const espp::Twai::Message &m) { + if (client_ptr) + client_ptr->process_frame(espp::CanopenClient::CanFrame{ + .id = m.id, .extended = m.extended, .rtr = m.rtr, .dlc = m.dlc, .data = m.data}); + }, + .log_level = espp::Logger::Verbosity::WARN, + }); + espp::CanopenClient client({ + .node_id = kNodeId, + .send = + [&twai](const espp::CanopenClient::CanFrame &f) { + espp::Twai::Message m{ + .id = f.id, .extended = f.extended, .rtr = f.rtr, .dlc = f.dlc, .data = f.data}; + std::error_code tx_ec; + return twai.transmit(m, tx_ec); + }, + .sdo_timeout = 500ms, + .log_level = espp::Logger::Verbosity::WARN, + }); + client_ptr = &client; + espp::Mcp266 mcp(client, {.log_level = espp::Logger::Verbosity::WARN}); + + // A single CANopen SDO channel: serialize every Mcp266 call (command handler + + // status streamer both issue SDO, and only one transaction may be in flight). + std::mutex mcp_mutex; + + std::error_code twai_ec; + const bool twai_ok = twai.initialize(twai_ec); + if (!twai_ok) + logger.error("Failed to initialize TWAI: {}", twai_ec.message()); + else { + // Best-effort node start so status works immediately; the web app can re-run + // it (START) if the node is not on the bus yet. + std::error_code ec; + std::lock_guard lock(mcp_mutex); + if (mcp.start(ec)) + logger.info("MCP266 node started"); + else + logger.warn("MCP266 not started ({}); use START from the web app once it is on the bus", + ec.message()); + } + + // --- USB: vendor (WebUSB) + CDC (Web Serial), both carry the protocol ------ + espp::UsbDevice::Config usb_cfg; + usb_cfg.manufacturer = "espp"; + usb_cfg.product = "espp MCP266 Console"; + usb_cfg.log_level = espp::Logger::Verbosity::WARN; + espp::UsbDevice::VendorFunction vendor; + vendor.interface_name = "espp MCP266 (WebUSB)"; + vendor.webusb = true; + vendor.landing_page_url = "esp-cpp.github.io/espp/apps/mcp266_console.html"; + usb_cfg.vendor = vendor; + espp::UsbDevice::CdcFunction cdc; + cdc.interface_name = "espp MCP266 (CDC)"; + usb_cfg.cdc = cdc; + espp::UsbDevice usb(usb_cfg); + + // Reply on whichever transport the host last talked on (only one at a time). + 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); + 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 = {}) { + const bool reply = (type & 0x80) != 0; // 0xE_ reply types set the frame reply flag + send(sf::build_frame(reply, proto::kModuleId, type, payload)); + }; + auto send_ok = [&](uint8_t request_type) { + const uint8_t p[] = {request_type}; + send_frame(proto::kOk, p); + }; + auto reply_error = [&](uint8_t request_type, const std::error_code &ec, const std::string &ctx) { + std::vector p; + p.push_back(request_type); + sf::put_u32(p, static_cast(ec.value())); + const std::string msg = ctx + ": " + ec.message(); + p.insert(p.end(), msg.begin(), msg.end()); + send_frame(proto::kError, p); + }; + + // --- STATUS snapshot: read both axes + device telemetry, send a STATUS frame + auto send_status = [&]() { + std::vector p; + uint8_t flags = 0; + bool any_ok = false; + { + std::lock_guard lock(mcp_mutex); + std::error_code ec; + for (Axis axis : {Axis::M1, Axis::M2}) { + int32_t position = 0, velocity = 0; + uint16_t statusword = 0; + if (mcp.read_encoder(axis, position, ec)) + any_ok = true; + mcp.read_speed(axis, velocity, ec); + mcp.read_statusword(axis, statusword, ec); + put_i32(p, position); + put_i32(p, velocity); + put_u16(p, statusword); + } + float volts = 0.0f, temp_c = 0.0f; + mcp.read_main_battery_voltage(volts, ec); + mcp.read_temperature(temp_c, ec); + put_u16(p, static_cast(volts * 10.0f + 0.5f)); + put_u16(p, static_cast(temp_c * 10.0f + 0.5f)); + } + if (any_ok) + flags |= proto::kStatusFlagOnline; + p.push_back(flags); + send_frame(proto::kStatus, p); + }; + + // --- status streaming task ------------------------------------------------- + // A STATUS snapshot performs eight blocking SDO reads while holding the shared + // MCP mutex (see send_status), so a too-small period would starve command + // handling and flood the CAN bus. Clamp the host-requested period into a safe + // window: >= 50 ms (<= 20 Hz) leaves headroom for the eight SDO round-trips, + // and <= 10 s keeps the stream responsive. A period of 0 selects the default. + static constexpr uint16_t kDefaultStreamPeriodMs = 200; + static constexpr uint16_t kMinStreamPeriodMs = 50; + static constexpr uint16_t kMaxStreamPeriodMs = 10000; + std::atomic stream_enabled{false}; + std::atomic stream_period_ms{kDefaultStreamPeriodMs}; + espp::Task status_task({.callback = [&](std::mutex &m, std::condition_variable &cv) -> bool { + if (stream_enabled.load()) + send_status(); + std::unique_lock lock(m); + cv.wait_for(lock, + std::chrono::milliseconds( + stream_enabled.load() ? stream_period_ms.load() : 200)); + return false; // keep running + }, + .task_config = {.name = "mcp266_status", .stack_size_bytes = 8192}}); + status_task.start(); + + // --- command handler (runs on the RX worker task, so SDO calls may block) -- + auto handle = [&](const sf::Frame &frame) { + if (frame.is_reply()) + return; // 0xE_ replies are what we SEND; never re-enter the request path + const uint8_t type = frame.type; + std::span pl = frame.payload; + std::error_code ec; + auto need = [&](size_t n) -> bool { + if (pl.size() < n) { + reply_error(type, std::make_error_code(std::errc::invalid_argument), "short payload"); + return false; + } + return true; + }; + // Axis-addressed requests: require the length AND a valid axis selector, so an + // unexpected pl[0] cannot silently fall through to M1 and command the wrong + // motor (axis_of() only distinguishes 1 == M2 from everything-else == M1). + auto need_axis = [&](size_t n) -> bool { + if (!need(n)) + return false; + if (pl[0] > proto::kAxisM2) { + reply_error(type, std::make_error_code(std::errc::invalid_argument), + "invalid axis (must be 0=M1 or 1=M2)"); + return false; + } + return true; + }; + std::lock_guard lock(mcp_mutex); + switch (type) { + case proto::kStart: + mcp.start(ec) ? send_ok(type) : reply_error(type, ec, "start failed"); + break; + case proto::kResetFaults: + mcp.reset_faults(ec) ? send_ok(type) : reply_error(type, ec, "reset faults failed"); + break; + case proto::kResetEstop: + mcp.reset_estop(ec) ? send_ok(type) : reply_error(type, ec, "reset e-stop failed"); + break; + case proto::kConfigurePositionLoop: + if (!need_axis(13)) + break; + mcp.configure_position_loop(axis_of(pl[0]), rd_i32(pl, 1), rd_i32(pl, 5), ec, rd_i32(pl, 9)) + ? send_ok(type) + : reply_error(type, ec, "configure position loop failed"); + break; + case proto::kSetPositionLimits: + if (!need_axis(9)) + break; + mcp.set_position_limits(axis_of(pl[0]), rd_i32(pl, 1), rd_i32(pl, 5), ec) + ? send_ok(type) + : reply_error(type, ec, "set position limits failed"); + break; + case proto::kMoveToPosition: + if (!need_axis(17)) + break; + mcp.move_to_position(axis_of(pl[0]), rd_i32(pl, 1), rd_u32(pl, 5), rd_u32(pl, 9), + rd_u32(pl, 13), ec) + ? send_ok(type) + : reply_error(type, ec, "move failed"); + break; + case proto::kDriveSpeed: + if (!need_axis(5)) + break; + mcp.drive_speed(axis_of(pl[0]), rd_i32(pl, 1), ec) + ? send_ok(type) + : reply_error(type, ec, "drive speed failed"); + break; + case proto::kDriveDuty: + if (!need_axis(3)) + break; + mcp.drive_duty(axis_of(pl[0]), rd_i16(pl, 1), ec) + ? send_ok(type) + : reply_error(type, ec, "drive duty failed"); + break; + case proto::kSetStatusStream: + if (!need(3)) + break; + { + const uint16_t requested = rd_u16(pl, 1); + const uint16_t period_ms = + std::clamp(requested == 0 ? kDefaultStreamPeriodMs : requested, + kMinStreamPeriodMs, kMaxStreamPeriodMs); + stream_enabled.store(pl[0] != 0); + stream_period_ms.store(period_ms); + } + send_ok(type); + break; + case proto::kGetDeviceInfo: { + std::string name; + uint32_t device_type = 0; + if (mcp.read_device_info(name, device_type, ec)) { + std::vector p; + sf::put_u32(p, device_type); + p.insert(p.end(), name.begin(), name.end()); + send_frame(proto::kDeviceInfo, p); + } else { + reply_error(type, ec, "read device info failed"); + } + break; + } + default: + reply_error(type, std::make_error_code(std::errc::not_supported), "unknown MCP266 message"); + break; + } + }; + + // kGetStatus is handled outside the mcp_mutex-holding switch (send_status + // locks it itself). Wrap the dispatch so GET_STATUS calls send_status(). + auto dispatch_frame = [&](const sf::Frame &frame) { + if (!frame.is_reply() && frame.type == proto::kGetStatus) { + send_status(); + return; + } + handle(frame); + }; + + espp::Dispatcher vendor_dispatcher, cdc_dispatcher; + vendor_dispatcher.register_module(proto::kModuleId, dispatch_frame); + cdc_dispatcher.register_module(proto::kModuleId, dispatch_frame); + + // --- USB RX plumbing: queue in the TinyUSB callback, dispatch from a worker - + std::mutex rx_mutex; + std::condition_variable rx_cv; + 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) { + // NOTE: active_transport is set by the RX worker just before it feeds each + // chunk (below), NOT here: a frame arriving on the other endpoint between + // enqueue and dispatch must not retarget a reply for the frame being handled. + { + 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) { + vendor_dispatcher.reset(); + cdc_dispatcher.reset(); + return false; + } + for (const auto &[source, chunk] : chunks) { + // Single-writer of active_transport: set it to match the chunk being + // dispatched so replies/status generated during this feed go back on + // the transport the request arrived on. + active_transport.store(source); + (source == Transport::Vendor ? vendor_dispatcher : cdc_dispatcher).feed(chunk); + } + return false; + }, + .task_config = {.name = "mcp266_rx", .stack_size_bytes = 16384}}); + rx_task.start(); + + if (usb_ok) + logger.info("MCP266 console ready. Connect the web app over WebUSB / Web Serial."); + + while (true) { + std::this_thread::sleep_for(1s); + } +} diff --git a/components/mcp266/webapp_example/sdkconfig.defaults b/components/mcp266/webapp_example/sdkconfig.defaults new file mode 100644 index 000000000..49e3129bc --- /dev/null +++ b/components/mcp266/webapp_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 MCP266 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 + +# Headroom on the vendor RX/TX FIFOs for status-stream bursts. +CONFIG_TINYUSB_VENDOR_RX_BUFSIZE=2048 +CONFIG_TINYUSB_VENDOR_TX_BUFSIZE=2048 + +# C++ +CONFIG_COMPILER_CXX_EXCEPTIONS=y