Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ jobs:
target: esp32s3
- path: 'components/odrive_ascii/example'
target: esp32
- path: 'components/odrive_native/example'
target: esp32
- path: 'components/pca9535/example'
target: esp32s3
- path: 'components/pcf85063/example'
Expand Down
34 changes: 34 additions & 0 deletions .github/workflows/odrive_native_interop.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: ODrive native interop (fibre serial loopback)

# Minimal token scope: the harness only checks out, builds, and fetches the
# reference fibre client; it never writes.
permissions:
contents: read

on:
pull_request:
paths:
- "components/odrive_native/**"
- "pc/tests/odrive_native_*"
- ".github/workflows/odrive_native_interop.yml"
workflow_dispatch:

# Supersede in-progress runs on the same PR (or ref for manual dispatch); keyed by
# workflow + PR number (globally unique, unlike a head branch two forks can share).
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

jobs:
interop:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Run fibre serial-loopback interop
run: |
cd components/odrive_native/interop
./run.sh
1 change: 1 addition & 0 deletions .github/workflows/upload_components.yml
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ jobs:
components/neopixel
components/nvs
components/odrive_ascii
components/odrive_native
components/pcf85063
components/pi4ioe5v
components/pid
Expand Down
8 changes: 8 additions & 0 deletions components/odrive_native/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# NOTE: unlike the vendored root-level detail/ folders in other espp components
# (format, cdr, hid-rp, ...), this component's detail/ lives INSIDE include/
# (include/detail/*.hpp), so registering "include" alone makes
# `#include "detail/odrive_native_core.hpp"` resolve for consumers.
idf_component_register(
INCLUDE_DIRS "include"
REQUIRES base_component
)
102 changes: 102 additions & 0 deletions components/odrive_native/PROTOCOL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# ODrive Native (legacy Fibre endpoint) protocol — implementation spec

Authoritative wire spec for `espp::OdriveNative`, extracted from the ODrive
firmware reference (`fw-v0.5.1`): `Firmware/fibre/python/fibre/protocol.py` and
`Firmware/fibre/cpp/include/fibre/protocol.hpp`.

**Target:** the legacy endpoint protocol (fw ≤ 0.5.x), **packet-based**, as used
over the USB **vendor** interface (one bulk IN + one bulk OUT). Each USB bulk
transfer carries exactly one packet (USB provides the reliability the UART
stream framing otherwise adds). Goal: `odrivetool` (legacy backend)
auto-discovers the object tree and does typed get/set. The newer 0.6+/Pro Fibre
is a different, larger stack and is out of scope for now.

## Constants
- `PROTOCOL_VERSION = 1`
- CRC8: init `0x42`, poly `0x37` (only used by the UART *stream* framing)
- CRC16: init `0x1337`, poly `0x3d65`

## CRC algorithm (both widths, **non-reflected, MSB-first, bit-by-bit**)
```
calc_crc(remainder, byte, poly, bitwidth): # byte in [0,255]
topbit = 1 << (bitwidth - 1)
remainder ^= byte << (bitwidth - 8)
repeat 8 times:
remainder = (remainder & topbit) ? ((remainder << 1) ^ poly)
: (remainder << 1)
return remainder & ((1 << bitwidth) - 1)
# CRC over a buffer: start from init, fold each byte through calc_crc.
```

## Packet format (host ⇄ device, little-endian throughout)
**Request** (host → device):
```
[seq_no u16 LE] # MSB (0x8000) clear in requests; client sets bit 0x80, masks 0x7fff
[endpoint_id u16 LE] # bit15 (0x8000) set => client expects a response; low 15 bits = endpoint #
[output_len u16 LE] # number of response bytes the client wants back
[payload ... ] # bytes to WRITE, or the read OFFSET (u32 LE) for endpoint 0; empty for a plain read
[trailer u16 LE] # canary: PROTOCOL_VERSION(1) if endpoint#==0, else json_crc
```
**Response** (device → host, emitted only if endpoint_id bit15 was set):
```
[seq_no u16 LE] # = request seq_no with MSB (0x8000) set
[data ... ] # up to output_len bytes (the value / json chunk); empty for a pure write
```
The server **must ignore** a request whose `trailer` != the expected canary
(PROTOCOL_VERSION for endpoint 0, else `json_crc`) — this is how the client and
server confirm they share the same object model.

## Server dispatch
- **endpoint 0** (the JSON blob): `payload` = offset (u32 LE). Respond with
`json[offset : offset + min(output_len, 512)]`. `offset >= len(json)` → empty
response (that is how the client's read loop terminates). trailer == PROTOCOL_VERSION.
- **endpoint N in registry**: if `payload` non-empty → deserialize per type and
**write** (when writable); if `output_len > 0` → serialize the current value
(per type, `output_len` bytes) into the response. trailer == `json_crc`.
- **unknown endpoint**: ignore (empty response).

## Type codecs (little-endian)
| type | size | notes |
|---------------|------|------------------|
| `bool` | 1 | 0/1 |
| `int8`/`uint8`| 1 | |
| `int16`/`uint16` | 2 | |
| `int32`/`uint32` | 4 | |
| `int64`/`uint64` | 8 | |
| `float` | 4 | IEEE-754 |
| `endpoint_ref`| 4 | `[endpoint u16][json_crc u16]` |

## JSON descriptor (the endpoint-0 blob)
Compact UTF-8 JSON (**no insignificant whitespace** — `json_crc` is over the
exact bytes). Top level is an **array** of the root object's members. Entries:
- property: `{"name":<str>,"id":<int>,"type":<primitive>,"access":"r"|"rw"|"w"}`
- object: `{"name":<str>,"type":"object","members":[ ... ]}`
- function: `{"name":<str>,"id":<int>,"type":"function","inputs":[...],"outputs":[...]}`

`json_crc` = `calc_crc16(json_bytes, init=PROTOCOL_VERSION=1)` — the endpoint
canary is seeded with `PROTOCOL_VERSION`, **not** the 0x1337 packet-CRC init. This
matches the fw-v0.5.1 firmware (`endpoints_template.j2`:
`json_crc_ = calc_crc16(PROTOCOL_VERSION, embedded_json, len)`) and the reference
fibre client (`discovery.py`: `calc_crc16(PROTOCOL_VERSION, json_bytes)`). The
server computes it over the bytes it emits; `odrivetool` computes it over the
bytes it reads; they must match byte-for-byte. (Verified by the serial-loopback
interop harness in `interop/`; only the 0x1337 init applies to the UART *stream*
framing CRC16 and the packet trailer of endpoint 0, which is `PROTOCOL_VERSION`.)

## `espp::OdriveNative` (transport-agnostic, mirrors `espp::OdriveAscii`)
- `std::vector<uint8_t> process_bytes(std::span<const uint8_t>)` — one packet in,
one response packet out (empty if none). The caller performs USB I/O.
- Registration builds a typed endpoint tree (getters/setters via `std::function`,
`std::error_code`, no exceptions); ids are assigned and the JSON + `json_crc`
are finalized at build time. Names use dotted paths like
`axis0.controller.input_pos`, mirroring the ASCII component.

## Verification plan
1. **CRC golden vectors** generated from the exact Python reference (above) — the
C++ `calc_crc16` must match bit-for-bit.
2. **Packet round-trip** host unit tests: crafted read / write / endpoint-0-read
requests → exact expected response bytes.
3. **Real interop** (later phase, the true gate): run genuine `fibre-python` /
`odrivetool` against the host build over a loopback and confirm it enumerates
the tree and reads/writes endpoints — mirrors how `rtps` is gated against
FastDDS / ROS 2.
72 changes: 72 additions & 0 deletions components/odrive_native/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# ODrive Native (Fibre endpoint) Protocol Component

[![Badge](https://components.espressif.com/components/espp/odrive_native/badge.svg)](https://components.espressif.com/components/espp/odrive_native)

`espp::OdriveNative` implements a transport-agnostic server for the **ODrive
legacy native (Fibre endpoint) binary protocol** (firmware <= 0.5.x), as used
over the USB vendor interface where each bulk transfer carries exactly one
packet. It parses one inbound request packet and produces one response packet;
it performs no I/O itself (the caller does USB/UART transport).

Applications register typed properties from dotted paths (mirroring
`espp::OdriveAscii`). Endpoint ids are assigned sequentially starting at 1
(endpoint 0 is the JSON descriptor blob), and the compact JSON descriptor and
its CRC are finalized lazily. This lets a legacy `odrivetool` / `fibre-python`
client auto-discover the object tree and perform typed get/set.

<!-- markdown-toc start - Don't edit this section. Run M-x markdown-toc-refresh-toc -->
**Table of Contents**

- [ODrive Native (Fibre endpoint) Protocol Component](#odrive-native-fibre-endpoint-protocol-component)
- [Features](#features)
- [API](#api)
- [Protocol](#protocol)
- [Example](#example)
- [Notes](#notes)

<!-- markdown-toc end -->

## Features

- **Transport-agnostic**: one packet in via `process_bytes`, one response packet out
- **Typed property registry**: `register_float_property`, and
`_int8_/_uint8_/_int16_/_uint16_/_int32_/_uint32_/_int64_/_uint64_/_bool_`
variants, each taking a getter and optional setter (no exceptions; uses
`std::error_code`)
- **Auto-discovery**: builds the endpoint-0 JSON descriptor + `json_crc` so a
legacy Fibre client can enumerate the tree
- **No hardware dependencies**: integrates via `std::function` callbacks
- **Thread-safe**: internal locking for the registry; user getters/setters are
never invoked while a lock is held (snapshot then call)
- **Host-buildable wire core**: the CRC/pack/codec/JSON/dispatch logic lives in
`detail::OdriveNativeCore`, which builds with just the standard library

## API

Key class: `espp::OdriveNative`
- `process_bytes(std::span<const uint8_t>) -> std::vector<uint8_t>` (one packet in, one out)
- Register properties: `register_float_property`, `register_int32_property`,
`register_uint32_property`, `register_bool_property`, and the other integer
width variants
- `finalize()`, `json()`, `json_crc()` inspect the generated descriptor

See header [`include/odrive_native.hpp`](./include/odrive_native.hpp) and
[`PROTOCOL.md`](./PROTOCOL.md) for details.

## Protocol

The authoritative wire specification (packet format, CRC, endpoint dispatch,
type codecs, and JSON schema) is documented in [`PROTOCOL.md`](./PROTOCOL.md).

## Example

A scripted example is provided in [`example`](./example) and is built by CI. It
registers a few simulated-motor properties and feeds crafted packets through
`process_bytes`, logging the responses.

## Notes

- This component implements the **properties** (primitive get/set) surface of the
legacy protocol; functions / endpoint refs are not implemented yet.
- Wiring to a concrete USB device stack is a later phase; this component is
purely the protocol server.
19 changes: 19 additions & 0 deletions components/odrive_native/example/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
cmake_minimum_required(VERSION 3.20)

set(ENV{IDF_COMPONENT_MANAGER} "0")
include($ENV{IDF_PATH}/tools/cmake/project.cmake)

set(EXTRA_COMPONENT_DIRS
"${CMAKE_CURRENT_LIST_DIR}/../.."
)

set(
COMPONENTS
"main esptool_py odrive_native"
CACHE STRING
"List of components to include"
)

project(odrive_native_example)

set(CMAKE_CXX_STANDARD 20)
50 changes: 50 additions & 0 deletions components/odrive_native/example/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# ODrive Native (Fibre endpoint) Example

This example demonstrates how to use the `espp::OdriveNative` component to serve
the ODrive legacy native (Fibre endpoint) binary protocol. It registers a few
simulated-motor properties, then feeds crafted request packets through
`process_bytes` and logs the responses:

1. an endpoint-0 read that returns the auto-generated JSON descriptor,
2. a binary write to `axis0.controller.input_pos`, and
3. a binary read of the same property.

<!-- markdown-toc start - Don't edit this section. Run M-x markdown-toc-refresh-toc -->
**Table of Contents**

- [ODrive Native (Fibre endpoint) Example](#odrive-native-fibre-endpoint-example)
- [Requirements](#requirements)
- [Build](#build)
- [Flash and Monitor](#flash-and-monitor)
- [Notes](#notes)

<!-- markdown-toc end -->

## Requirements

- ESP-IDF installed and `get_idf` available in your shell

## Build

```sh
# From repo root
cd components/odrive_native/example
get_idf
idf.py build
```

## Flash and Monitor

```sh
idf.py flash monitor
```

The example runs a scripted packet sequence and logs the descriptor and the
per-packet responses.

## Notes

- The component is transport-agnostic: this example fabricates packets in code.
In a real deployment each USB bulk transfer would carry one packet, which you
pass to `process_bytes`, transmitting the returned response bytes back.
- Wiring to a concrete USB device stack is a later phase.
4 changes: 4 additions & 0 deletions components/odrive_native/example/main/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
idf_component_register(
SRC_DIRS "."
INCLUDE_DIRS "."
)
Loading
Loading