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
+
+
Connect (WebUSB)
+
Connect (Web Serial)
+
Disconnect
+
Any device (ignore VID/PID filter)
+
+
Start node
+
Reset faults
+
Reset e-stop
+
+
Not connected. Default WebUSB filter: VID 0x1209 / PID 0x0d32 (espp default).
+
+
Device type: -
+
Name: -
+
Battery: -
+
Temp: -
+
+
Live status
+
Refresh
+
+
+
+
+
+
+
+
+
+
+ espp MCP266 Console · talks to the espp MCP266 webapp example
+ (components/mcp266/webapp_example ), which runs the
+ espp::Mcp266 driver over CANopen. Single-file, offline, no dependencies.
+
+
+
+
+
+
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