Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
12b33dc
feat(usb_device): native USB CDC transport (esp_tinyusb) + OdriveAsci…
finger563 Aug 17, 2026
e50ef49
feat(usb_device): vendor + WebUSB interface, composable multi-class d…
finger563 Aug 17, 2026
341523f
feat(odrive_native): ODrive legacy native (Fibre endpoint) protocol s…
finger563 Aug 17, 2026
2b4de24
test(odrive_native): fibre serial-loopback interop harness + stream f…
finger563 Aug 17, 2026
9b6ef1f
Merge remote-tracking branch 'origin/feat/odrive-native' into feat/od…
finger563 Aug 17, 2026
c544779
feat(usb_device): ODrive-compatible USB example — native/Fibre on ven…
finger563 Aug 17, 2026
f643cc8
fix(usb_device): pin esp32s3 target in the example sdkconfig.defaults
finger563 Aug 17, 2026
6d1e567
fix(usb_device): auto-detect/clone the reference fibre in the USB probe
finger563 Aug 17, 2026
247534c
feat(usb_device): HID function + example-manifest cleanup + PR #720 r…
finger563 Aug 18, 2026
e2d13c4
docs(usb_device): fix stale example CMakeLists comment (no override_p…
finger563 Aug 18, 2026
7cc414a
Merge remote-tracking branch 'origin/feat/usb-cdc-transport' into fea…
finger563 Aug 18, 2026
8f87abf
docs(usb_device): add HID gamepad + WebHID visualizer to the hardware…
finger563 Aug 18, 2026
3b3f9f4
fix(usb_device): drop invalid CDC-ACM tag, require esp_tinyusb >=2.0
finger563 Aug 18, 2026
401df55
fix(usb_device example): sync odrive_native static-analysis fixes + c…
finger563 Aug 18, 2026
8b6c668
fix(odrive_native): ignore unknown endpoints even with expect-respons…
finger563 Aug 18, 2026
1b0e10f
fix(odrive_native): enforce stream_frame packet<128 guard (sync with …
finger563 Aug 18, 2026
f0cb6d3
chore(odrive_native): sync #725 copy with #721 (dedupe divergent comp…
finger563 Aug 18, 2026
f5f2939
chore(odrive_native): sync with #721 (error hook + endpoint-id cap)
finger563 Aug 18, 2026
f6ff1df
chore: sync odrive_ascii (#719) + usb_device (#720) copies with their…
finger563 Aug 18, 2026
13dc4fe
chore(odrive_native): sync with #721 (Linux interop fix + cppcheck st…
finger563 Aug 18, 2026
a0bfdc9
chore(odrive_native): sync with #721 (CMake layout note + unused test…
finger563 Aug 18, 2026
5e4ec4a
Merge remote-tracking branch 'origin/main' into feat/odrive-usb-native
finger563 Aug 18, 2026
24b10bb
chore: restore usb_device/web docs-hosting copy after main merge (mat…
finger563 Aug 18, 2026
74d984f
fix(odrive web): address post-merge #723 review — reset-based recover…
finger563 Aug 18, 2026
8731daf
chore(usb_device): sync with #720 (empty-HID-descriptor rejection, st…
finger563 Aug 18, 2026
de4eb9a
Merge remote-tracking branch 'origin/main' into feat/odrive-usb-native
finger563 Aug 18, 2026
d1d05ee
chore(usb_device): sync with #720 (cppcheck unreachableCode fix)
finger563 Aug 18, 2026
ff336c6
chore(usb_device): sync with #720 (atomic singleton claim)
finger563 Aug 18, 2026
67322fc
chore(usb_device): sync with #720 (HID descriptor arg-order comment)
finger563 Aug 18, 2026
30da7ef
Merge remote-tracking branch 'origin/main' into feat/odrive-usb-native
finger563 Aug 18, 2026
e7da38d
chore: drop stale pc harness special-case re-added by branch history
finger563 Aug 18, 2026
33473d7
Merge remote-tracking branch 'origin/main' into feat/odrive-usb-native
finger563 Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions components/odrive_ascii/web/hid_visualizer.html
Original file line number Diff line number Diff line change
Expand Up @@ -809,8 +809,22 @@ <h2>Log <span class="spacer"></span><button id="clearLogBtn" class="small">Clear
["Collections", String((dev.collections || []).length)],
["Input reports", String(model.byId.size)],
];
els.devInfo.innerHTML = rows.map((r) =>
`<div class="row"><span class="k">${r[0]}</span><span class="v">${r[1]}</span></div>`).join("");
// Build with DOM nodes + textContent: productName comes from the device
// descriptor and is attacker-controlled, so it must never be
// interpolated into innerHTML (a malicious device name could inject
// active markup into the page).
els.devInfo.replaceChildren(...rows.map((r) => {
const row = document.createElement("div");
row.className = "row";
const k = document.createElement("span");
k.className = "k";
k.textContent = r[0];
const v = document.createElement("span");
v.className = "v";
v.textContent = r[1];
row.append(k, v);
return row;
}));
}

async function disconnect() {
Expand Down
116 changes: 80 additions & 36 deletions components/odrive_ascii/web/odrive_control_panel.html
Original file line number Diff line number Diff line change
Expand Up @@ -510,35 +510,56 @@ <h2>Device</h2>
// decode(Uint8Array)->number|bigint|boolean. ----
const TYPES = {
bool: { size: 1, dec: (dv) => dv.getUint8(0) !== 0, enc: (v) => { const b = new Uint8Array(1); b[0] = boolFrom(v) ? 1 : 0; return b; }, num: (x) => (x ? 1 : 0) },
int8: { size: 1, dec: (dv) => dv.getInt8(0), enc: (v) => i(1, (dv, n) => dv.setInt8(0, n), v), num: (x) => x },
uint8: { size: 1, dec: (dv) => dv.getUint8(0), enc: (v) => i(1, (dv, n) => dv.setUint8(0, n), v), num: (x) => x },
int16: { size: 2, dec: (dv) => dv.getInt16(0, true), enc: (v) => i(2, (dv, n) => dv.setInt16(0, n, true), v), num: (x) => x },
uint16: { size: 2, dec: (dv) => dv.getUint16(0, true), enc: (v) => i(2, (dv, n) => dv.setUint16(0, n, true), v), num: (x) => x },
int32: { size: 4, dec: (dv) => dv.getInt32(0, true), enc: (v) => i(4, (dv, n) => dv.setInt32(0, n, true), v), num: (x) => x },
uint32: { size: 4, dec: (dv) => dv.getUint32(0, true), enc: (v) => i(4, (dv, n) => dv.setUint32(0, n, true), v), num: (x) => x },
int64: { size: 8, dec: (dv) => dv.getBigInt64(0, true), enc: (v) => big(8, (dv, n) => dv.setBigInt64(0, n, true), v), num: (x) => Number(x) },
uint64: { size: 8, dec: (dv) => dv.getBigUint64(0, true), enc: (v) => big(8, (dv, n) => dv.setBigUint64(0, n, true), v), num: (x) => Number(x) },
float: { size: 4, dec: (dv) => dv.getFloat32(0, true), enc: (v) => { const b = new Uint8Array(4); new DataView(b.buffer).setFloat32(0, parseFloat(v), true); return b; }, num: (x) => x },
int8: { size: 1, dec: (dv) => dv.getInt8(0), enc: (v) => i(1, (dv, n) => dv.setInt8(0, n), v, -128, 127), num: (x) => x },
uint8: { size: 1, dec: (dv) => dv.getUint8(0), enc: (v) => i(1, (dv, n) => dv.setUint8(0, n), v, 0, 255), num: (x) => x },
int16: { size: 2, dec: (dv) => dv.getInt16(0, true), enc: (v) => i(2, (dv, n) => dv.setInt16(0, n, true), v, -32768, 32767), num: (x) => x },
uint16: { size: 2, dec: (dv) => dv.getUint16(0, true), enc: (v) => i(2, (dv, n) => dv.setUint16(0, n, true), v, 0, 65535), num: (x) => x },
int32: { size: 4, dec: (dv) => dv.getInt32(0, true), enc: (v) => i(4, (dv, n) => dv.setInt32(0, n, true), v, -2147483648, 2147483647), num: (x) => x },
uint32: { size: 4, dec: (dv) => dv.getUint32(0, true), enc: (v) => i(4, (dv, n) => dv.setUint32(0, n, true), v, 0, 4294967295), num: (x) => x },
int64: { size: 8, dec: (dv) => dv.getBigInt64(0, true), enc: (v) => big(8, (dv, n) => dv.setBigInt64(0, n, true), v, -(2n ** 63n), 2n ** 63n - 1n), num: (x) => Number(x) },
uint64: { size: 8, dec: (dv) => dv.getBigUint64(0, true), enc: (v) => big(8, (dv, n) => dv.setBigUint64(0, n, true), v, 0n, 2n ** 64n - 1n), num: (x) => Number(x) },
float: { size: 4, dec: (dv) => dv.getFloat32(0, true), enc: (v) => f32(v), num: (x) => x },
endpoint_ref: { size: 4, dec: (dv) => `#${dv.getUint16(0, true)}:0x${dv.getUint16(2, true).toString(16)}`, enc: null, num: null },
};
function boolFrom(v) { return /^(1|true|on|yes|y)$/i.test(String(v).trim()); }
function i(size, set, v) {
// These values command motors, so encoding is STRICT: the whole string must
// parse and fit the target type -- no partial parses ("1abc"), no NaN, no
// truncation ("1.9" as an int) and no wrap (uint8 300). writeEndpoint
// surfaces the thrown message to the user instead of transmitting garbage.
function i(size, set, v, min, max) {
const s = String(v).trim();
if (!/^[+-]?\d+$/.test(s)) throw new Error("'" + v + "' is not a valid integer");
const n = Number(s);
if (!Number.isSafeInteger(n) || n < min || n > max)
throw new Error("'" + v + "' is out of range [" + min + ", " + max + "]");
const b = new Uint8Array(size);
let n = Math.trunc(Number(v));
if (!Number.isFinite(n)) n = 0;
set(new DataView(b.buffer), n);
return b;
}
function big(size, set, v) {
const b = new Uint8Array(size);
function big(size, set, v, min, max) {
const s = String(v).trim();
let n;
// Throw on a non-integer string (e.g. "1.5") instead of silently
// writing 0 to the device; writeEndpoint surfaces the message.
try { n = BigInt(String(v).trim()); }
try { n = BigInt(s); }
catch (_) { throw new Error("'" + v + "' is not a valid integer"); }
if (n < min || n > max)
throw new Error("'" + v + "' is out of range [" + min + ", " + max + "]");
const b = new Uint8Array(size);
set(new DataView(b.buffer), n);
return b;
}
function f32(v) {
const s = String(v).trim();
if (!/^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/.test(s))
throw new Error("'" + v + "' is not a valid number");
const n = parseFloat(s);
// Math.fround tells us what the wire's float32 will hold; a finite
// double that rounds to +/-Infinity in float32 would overflow.
if (!Number.isFinite(n) || !Number.isFinite(Math.fround(n)))
throw new Error("'" + v + "' overflows float32");
const b = new Uint8Array(4);
new DataView(b.buffer).setFloat32(0, n, true);
return b;
}
function typeInfo(t) { return TYPES[t] || null; }
function isPlottable(ep) {
const ti = typeInfo(ep.type);
Expand Down Expand Up @@ -703,7 +724,10 @@ <h2>Device</h2>
if (!found) throw new Error("no vendor-specific (0xFF) interface with a bulk IN+OUT endpoint pair was found");
await device.claimInterface(found.interfaceNumber);
if (found.alternateSetting !== 0) {
try { await device.selectAlternateInterface(found.interfaceNumber, found.alternateSetting); } catch (_) {}
// The chosen endpoints belong to this alternate; if selecting it fails
// they are not active, so let the rejection propagate and fail the
// connect (instead of reporting Connected with dead endpoints).
await device.selectAlternateInterface(found.interfaceNumber, found.alternateSetting);
}
ifaceNumber = found.interfaceNumber;
epIn = found.inEp.endpointNumber; epOut = found.outEp.endpointNumber;
Expand Down Expand Up @@ -763,14 +787,22 @@ <h2>Device</h2>

async function readPacket(readLen, timeoutMs) {
let timer = null;
// WebUSB transfers are NOT cancelable: on timeout the transferIn below
// stays pending and would consume a LATER response, permanently
// desyncing the link. On timeout we silence the abandoned promise and
// tag the error so transact() runs recoverLink(), whose device.reset()
// aborts the pending transfer for real.
const xfer = device.transferIn(epIn, readLen);
const timeout = new Promise((_, reject) => {
timer = setTimeout(async () => {
try { await device.clearHalt("in", epIn); } catch (_) {}
reject(new Error("response timeout (" + timeoutMs + "ms)"));
timer = setTimeout(() => {
xfer.catch(() => {}); // rejects when recoverLink() resets the device
const e = new Error("response timeout (" + timeoutMs + "ms)");
e.needsRecovery = true;
reject(e);
}, timeoutMs);
});
try {
const result = await Promise.race([device.transferIn(epIn, readLen), timeout]);
const result = await Promise.race([xfer, timeout]);
if (result.status === "stall") {
try { await device.clearHalt("in", epIn); } catch (_) {}
throw new Error("IN endpoint stalled");
Expand All @@ -784,18 +816,25 @@ <h2>Device</h2>
} finally { if (timer) clearTimeout(timer); }
}

// Best-effort resync after a desync (e.g. sequence mismatch): drain one
// stray IN packet if the device has one queued, then clear the IN halt so
// the next transaction starts from a clean endpoint. Never throws.
async function resyncIn() {
if (!device || epIn === null) return;
// Recover a desynced link (response timeout / sequence mismatch). WebUSB
// transfers cannot be canceled, so draining or clearing halts cannot
// reliably resynchronize -- an abandoned transferIn keeps competing for
// the next response. Instead reset the device: that aborts every pending
// transfer (their promises reject) and returns the endpoints to a clean
// state, with the browser restoring the configuration and interface
// claims. If even the reset fails, close the connection so the UI
// reflects reality instead of silently misbehaving.
async function recoverLink(reason) {
if (!device) return;
logLine("err", "Recovering link (" + reason + "): resetting device...");
try {
const drain = new Promise((resolve) =>
device.transferIn(epIn, inPacketSize).then(resolve, resolve));
const timeout = new Promise((resolve) => setTimeout(resolve, 50));
await Promise.race([drain, timeout]);
} catch (_) {}
try { await device.clearHalt("in", epIn); } catch (_) {}
await device.reset();
logLine("sys", "Device reset complete; link resynchronized.");
} catch (e) {
logLine("err", "Device reset failed (" + e.message + "); disconnecting.");
manualDisconnect = false;
await safeClose();
}
}

// Send one request packet and await its one response packet. Returns the
Expand Down Expand Up @@ -828,13 +867,18 @@ <h2>Device</h2>
// resync the link by draining any straggler packet + clearing the
// IN halt. Transactions are serialized, so this runs to completion
// before the next one starts.
logLine("err", `Sequence mismatch: sent ${req.seq}, got ${respSeq & 0x7fff} (link desynced); resyncing.`);
await resyncIn();
logLine("err", `Sequence mismatch: sent ${req.seq}, got ${respSeq & 0x7fff} (link desynced).`);
await recoverLink("sequence mismatch");
return null;
}
return resp.subarray(2);
} catch (e) {
if (!manualDisconnect) logLine("err", "Transaction failed: " + e.message);
if (!manualDisconnect) {
logLine("err", "Transaction failed: " + e.message);
// A timed-out read leaves an uncancelable transferIn pending;
// reset the device before the next transaction runs.
if (e && e.needsRecovery) await recoverLink(e.message);
}
return null;
}
});
Expand Down
8 changes: 5 additions & 3 deletions components/odrive_ascii/web/odrive_webusb_console.html
Original file line number Diff line number Diff line change
Expand Up @@ -762,10 +762,12 @@ <h2>Misc</h2>
}

await device.claimInterface(found.interfaceNumber);
// Select the alternate setting if it isn't the default (0).
// Select the alternate setting if it isn't the default (0). The chosen
// endpoints belong to this alternate; if selecting it fails they are not
// active, so let the rejection propagate to the connect failure handler
// (instead of reporting Connected while every transfer fails).
if (found.alternateSetting !== 0) {
try { await device.selectAlternateInterface(found.interfaceNumber, found.alternateSetting); }
catch (_) { /* some platforms don't require/allow this; ignore */ }
await device.selectAlternateInterface(found.interfaceNumber, found.alternateSetting);
}

ifaceNumber = found.interfaceNumber;
Expand Down
3 changes: 3 additions & 0 deletions components/usb_device/example/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Runtime artifacts for the USB hardware probe (odrive_usb_probe.py); never committed.
odrive-ref/
.venv-usb/
3 changes: 2 additions & 1 deletion components/usb_device/example/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,13 @@ set(EXTRA_COMPONENT_DIRS
"../../../components/hid-rp"
"../../../components/logger"
"../../../components/odrive_ascii"
"../../../components/odrive_native"
"../../../components/usb_device"
)

set(
COMPONENTS
"main esptool_py base_component format hid-rp logger odrive_ascii usb_device esp_tinyusb"
"main esptool_py base_component format hid-rp logger odrive_ascii odrive_native usb_device esp_tinyusb"
CACHE STRING
"List of components to include"
)
Expand Down
92 changes: 92 additions & 0 deletions components/usb_device/example/HARDWARE_TEST.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# Hardware test: ODrive-compatible USB device (ASCII on CDC + native/Fibre on vendor)

This example makes an ESP32-S3 (or -S2/-P4) enumerate as an **ODrive-compatible
USB device** on its native USB-OTG peripheral, presenting two interfaces from one
simulated motor state:

- **CDC serial** → the **ODrive ASCII** protocol (text; terminal / the Web Serial console).
- **vendor (0xFF, WebUSB)** → the **ODrive native (Fibre) binary** protocol — the one
`odrivetool` / the `fibre` library auto-discover over USB.

VID/PID default to `0x1209 / 0x0d32` (ODrive v3-like). The log console stays on the
built-in USB-Serial-JTAG, separate from this device.

> Board note: on single-USB-connector S3 devkits the USB-OTG and USB-Serial-JTAG
> share pins. Use a board that exposes the USB-OTG D+/D- (a second connector or the
> OTG header), or set the console to UART, so the native USB device enumerates.

## 1. Flash

```sh
cd components/usb_device/example
idf.py set-target esp32s3
idf.py -p <PORT> flash monitor
```
The monitor prints the endpoint-tree size + `json_crc` once USB is up.

## 2. Verify the native (Fibre) interface over USB — the real gate

`odrivetool` from `pip install odrive` (0.6+) uses the **new** libfibre/protocol and
will **not** talk to this legacy-protocol device. Use the **reference legacy fibre**
(pure python), which is exactly what the codec targets and what the interop harness
already clones.

```sh
# libusb + a venv with pyusb (the fibre USB backend uses pyusb)
brew install libusb # macOS (Linux: apt install libusb-1.0-0)
python3 -m venv .venv-usb && . .venv-usb/bin/activate
pip install pyusb appdirs

# run the probe -- it fetches the reference fibre for you with --clone
python odrive_usb_probe.py --clone
```
The reference fibre is pure-python (only needs pyserial/pyusb). `--clone` shallow-
clones ODrive `fw-v0.5.1` into `./odrive-ref` and uses `Firmware/fibre/python` from
it. Alternatives to `--clone`:
- if you've already run `components/odrive_native/interop/run.sh`, the probe
**auto-detects** the clone it made — just `python odrive_usb_probe.py`;
- or clone it yourself and point at it:
```sh
git clone --depth 1 -b fw-v0.5.1 https://github.com/odriverobotics/ODrive /tmp/odrive-ref
python odrive_usb_probe.py --fibre-path /tmp/odrive-ref/Firmware/fibre/python
```

Expected: it discovers the board over USB, downloads endpoint 0, enumerates
`vbus_voltage / axis0.* / serial_number`, reads values, and writes-then-reads
`input_pos` and `vel_limit` — `ALL PROBE ASSERTIONS PASSED`.

On **Linux** you may need `sudo` or a udev rule to claim the vendor interface. On
**Windows** the device must bind WinUSB (the firmware advertises MS-OS-2.0, so it
should bind automatically).

Legacy `odrivetool` (`pip install 'odrive==0.5.6'` in a Python ≤3.10 env) should also
auto-discover it as `odrv0`; the reference-fibre probe above avoids that install.

## 3. Verify the ASCII interface (CDC)

The device also shows up as a **serial/CDC port**. Send ODrive ASCII lines with any
terminal, e.g. `r axis0.encoder.pos_estimate`, `p 0 1.0`, `f 0`. Or open the hosted
**Web Serial console** and pick this CDC port. (Writes/setpoints are silent by
default — ODrive semantics; only `r`/`f` respond.)

## 4. Verify WebUSB (browser)

Open `components/odrive_ascii/web/odrive_control_panel.html` (native-protocol control
panel) in Chromium and Connect; it claims the vendor interface directly (no driver) and
shows the endpoint tree with live reads/plots. The firmware's WebUSB landing-page
descriptor also points a browser at the hosted console.

## 5. Verify the HID gamepad (WebHID)

The device also enumerates as a **HID gamepad**: the firmware animates both analog
sticks and toggles two buttons at ~10 Hz. Your OS will see a gamepad. To visualize the
input reports directly in the browser, open `components/odrive_ascii/web/hid_visualizer.html`
in Chromium and Connect (pick the espp gamepad) — you should see the sticks circling and
buttons blinking. WebHID reads the HID interface directly (no driver); it works on the
same composite device without disturbing the CDC/vendor interfaces.

## What "good" looks like
- The USB probe prints the full endpoint tree and `ALL PROBE ASSERTIONS PASSED`.
- The CDC port answers `r`/`f`.
- If all three work, the ASCII + native + USB stack is validated end-to-end on real
hardware — then it's safe to open the PRs.
Loading
Loading