Adding custom endpoint type to Odrive to send back multiple sensor values at once

Hello,

I’ve been trying to implement a mod to the Odrive 3.6 firmware. I created a new axis attribute consisting of 6 float struct. The reason is so that using the native protocole I can access all the values in one call to increase communication fequency. I need the encoder positions, velocities and motor torques. So far I manage to implement everything except that now I need to write the encode and decode function in protocole.hpp. The problem is that the size of the output buffer is zero and so I cannot encode my structure into it.

here is everything I did :

in my odrive-interface.yaml file I adde this line to Odrive.axis attributes :

sensor_estimates: readonly DualAxisSensorData 

in protocol.hpp I adde my struct definition:

typedef struct { 
    float p0; 
    float v0; 
    float t0; 
    float p1; 
    float v1; 
    float t1;
    
    float& operator[](int index) {
        switch (index) {
            case 0: return p0;
            case 1: return v0;
            case 2: return t0;
            case 3: return p1;
            case 4: return v1;
            case 5: return t1;
            default:
                return p0;
        }
    }
    // Also need a const version for reading:
    const float& operator[](int index) const {
        switch (index) {
            case 0: return p0;
            case 1: return v0;
            case 2: return t0;
            case 3: return p1;
            case 4: return v1;
            case 5: return t1;
            default:
                return p0;
        }
    }

} DualAxisSensorData;

in Axis.hpp I defined the attribute and function to udate it :

void set_sensor();
DualAxisSensorData sensor_estimates_ = {0.0f,0.0f,0.0f,0.0f,0.0f,0.0f};

in Axis.cpp i defined the set_sensor function :

void Axis::set_sensor(){
    sensor_estimates_[0] = axes[0].encoder_.pos_estimate_.any().value_or(0.0f);
    sensor_estimates_[1] = axes[0].encoder_.vel_estimate_.any().value_or(0.0f);
    sensor_estimates_[2] = axes[0].controller_.torque_output_.any().value_or(0.0f);

    sensor_estimates_[3] = axes[1].encoder_.pos_estimate_.any().value_or(0.0f);
    sensor_estimates_[4] = axes[1].encoder_.vel_estimate_.any().value_or(0.0f);
    sensor_estimates_[5] = axes[1].controller_.torque_output_.any().value_or(0.0f);
}

in interface_generator I modified the value_types to add mine :

value_types = OrderedDict({
    'bool': {'builtin': True, 'fullname': 'bool', 'name': 'bool', 'c_name': 'bool', 'py_type': 'bool'},
    'float32': {'builtin': True, 'fullname': 'float32', 'name': 'float32', 'c_name': 'float', 'py_type': 'float'},
    'uint8': {'builtin': True, 'fullname': 'uint8', 'name': 'uint8', 'c_name': 'uint8_t', 'py_type': 'int'},
    'uint16': {'builtin': True, 'fullname': 'uint16', 'name': 'uint16', 'c_name': 'uint16_t', 'py_type': 'int'},
    'uint32': {'builtin': True, 'fullname': 'uint32', 'name': 'uint32', 'c_name': 'uint32_t', 'py_type': 'int'},
    'uint64': {'builtin': True, 'fullname': 'uint64', 'name': 'uint64', 'c_name': 'uint64_t', 'py_type': 'int'},
    'int8': {'builtin': True, 'fullname': 'int8', 'name': 'int8', 'c_name': 'int8_t', 'py_type': 'int'},
    'int16': {'builtin': True, 'fullname': 'int16', 'name': 'int16', 'c_name': 'int16_t', 'py_type': 'int'},
    'int32': {'builtin': True, 'fullname': 'int32', 'name': 'int32', 'c_name': 'int32_t', 'py_type': 'int'},
    'int64': {'builtin': True, 'fullname': 'int64', 'name': 'int64', 'c_name': 'int64_t', 'py_type': 'int'},
    'endpoint_ref': {'builtin': True, 'fullname': 'endpoint_ref', 'name': 'endpoint_ref', 'c_name': 'endpoint_ref_t', 'py_type': '[not implemented]'},
    'DualAxisSensorData': {'builtin': True, 'fullname': 'DualAxisSensorData', 'name': 'DualAxisSensorData', 'c_name': 'DualAxisSensorData', 'py_type': 'list'},
})

and finally I modified libfibre.py to decode on the python side:

class DualAxisSensorDataStructCodec():
    """
    Serializer/deserializer for the custom DualAxisSensorData struct (6 consecutive floats).
    """
    _struct_format = "<ffffff" # Little-endian (<) for 6 floats (f). Total size: 24 bytes.
    _length = struct.calcsize(_struct_format)

    def get_length(self):
        return self._length

    def serialize(self, libfibre, value):
        # This assumes 'value' is an object (or dict/tuple) containing the six fields.
        # Since the higher layer of ODrive is designed to pass field-named objects,
        # we try to access them by name.
        return struct.pack(self._struct_format, 
            value.p0, value.v0, value.t0, 
            value.p1, value.v1, value.t1)

    def deserialize(self, libfibre, buffer):
        # Deserializing a struct returns a tuple of its primitive values.
        # The higher-level ODrive code will map these to the named fields.
        return struct.unpack(self._struct_format, buffer)

codecs = {
    'int8': StructCodec("<b", int),
    'uint8': StructCodec("<B", int),
    'int16': StructCodec("<h", int),
    'uint16': StructCodec("<H", int),
    'int32': StructCodec("<i", int),
    'uint32': StructCodec("<I", int),
    'int64': StructCodec("<q", int),
    'uint64': StructCodec("<Q", int),
    'bool': StructCodec("<?", bool),
    'float': StructCodec("<f", float),
    'object_ref': ObjectPtrCodec(),
    'DualAxisSensorData': DualAxisSensorDataStructCodec()
}

for debugging purpose I show the expected and actual buffer size in the RemoteFunction class :

    async def async_call(self, args, cancellation_token):
        #print("making call on " + hex(args[0]._obj_handle))
        tx_buf = bytes()
        for i, arg in enumerate(self._inputs):
            tx_buf += arg[2].serialize(self._libfibre, args[i])
        rx_buf = bytes()

        agen = Call(self)
        
        if not cancellation_token is None:
            cancellation_token.add_done_callback(agen.cancel)

        try:
            assert(await agen.asend(None) is None)

            is_closed = False
            while not is_closed:
                tx_buf, rx_chunk, is_closed = await agen.asend((tx_buf, self._rx_size - len(rx_buf), True))
                rx_buf += rx_chunk

        finally:
            if not cancellation_token is None:
                cancellation_token.remove_done_callback(agen.cancel)

        # --- DEBUGING ---
        import sys
        print(f"\n[FIBRE DEBUG] Expected RX size (self._rx_size): {self._rx_size}", file=sys.stderr)
        print(f"[FIBRE DEBUG] Actual RX size (len(rx_buf)): {len(rx_buf)}", file=sys.stderr)
        print(f"[FIBRE DEBUG] Raw Buffer: {rx_buf}", file=sys.stderr)
        # --- END DEBUGING ---
        
        assert(len(rx_buf) == self._rx_size)

And on the Odrive I implemented the encoding and decoding here and also use debugging to print the buffer size :

template<> struct Codec<DualAxisSensorData> {
    static std::optional<DualAxisSensorData> decode(cbufptr_t* buffer) {
        // printf("DEBUG: DualAxisSensorData decode entered.\n");

        std::optional<float> p0 = Codec<float>::decode(buffer);
        std::optional<float> v0 = Codec<float>::decode(buffer);
        std::optional<float> t0 = Codec<float>::decode(buffer);
        std::optional<float> p1 = Codec<float>::decode(buffer);
        std::optional<float> v1 = Codec<float>::decode(buffer);
        std::optional<float> t1 = Codec<float>::decode(buffer);
        return (p0.has_value() && v0.has_value() && t0.has_value() && p1.has_value() && v1.has_value() && t1.has_value()) ? std::make_optional(DualAxisSensorData({*p0, *v0, *t0, *p1, *v1, *t1})) : std::nullopt;
    }
    
    static bool encode(const DualAxisSensorData& value, bufptr_t* output_buffer) {
        printf("DEBUG: buffer size %d\n",output_buffer->size()); // here size is 0

        bool success = Codec<float>::encode(value.p0, output_buffer) 
                    && Codec<float>::encode(value.v0, output_buffer) 
                    && Codec<float>::encode(value.t0, output_buffer) 
                    && Codec<float>::encode(value.p1, output_buffer) 
                    && Codec<float>::encode(value.v1, output_buffer) 
                    && Codec<float>::encode(value.t1, output_buffer);
        
        if (success) {
            printf("DEBUG: encode success.\n");
        } else {
            // This is where a failure from an internal Codec call will land.
            printf("DEBUG: encode fail (Buffer exhausted or internal float codec failure).\n");
        }

        return success; 
    }
};

everything build and I can flash the firmware on the Odrive. I can see the attribute but when I try to read it i get the size error :

[FIBRE DEBUG] Expected RX size (self._rx_size): 24
[FIBRE DEBUG] Actual RX size (len(rx_buf)): 0
[FIBRE DEBUG] Raw Buffer: b’’

If you could help me I would be very greatful.
thanks

Hm, that’s definitely pretty advanced functionality. Why not use something like CAN? The cyclic messages should let you get all the data you need at around 1kHz, assuming a 1Mbit CAN bus.

Maybe Codec<float>::encode isn’t properly incrementing the buffer size? Are you sure you can call it multiple times like that?

Hi solomondg,

Thanks for your response, and sorry for not answering sooner; I missed the notification and forgot about this issue.

In the meantime, I used a uint64 and encoded four floating-point values onto 16 bits each. This, however, does not meet the six float requirement and sacrifices some precision while limiting the value ranges.

The problem with CAN is that I will have six ODrive boards, each with six values to send at 1 kHz or more. I don’t think it’s possible to meet my requirements, as the CAN messages are apparently limited to 8 bytes as well. Right now, I can achieve around 5 kHz with one board using my uint64 trick, and I’m hoping to get around 6 kHz with all six boards using some multithreading.

Regarding Codec<float>::encode, I implemented it this way based on the implementation of endpoint_ref_t. Since the float is just reusing a uint32_t with casting, I didn’t think that would be an issue.

Thanks.

That all makes sense! You could do this with CAN-FD on the ODrive Pro/S1 at 1kHz using the periodic messages, but it’s definitely approaching the bandwidth limitation. You could maybe split it to multiple CAN busses? That’s a fairly common approach for high data rates.

I hadn’t thought about multiple CAN buses, but even if one CAN bus per ODrive is used, it still means reading the six values sequentially (3 if I encode two 32-bit floats into one int64 value) at 1kHz max. On top of that, the computer will have to communicate with all buses also. So I think implementing a custom type would offer better performance. I’m focusing on performance here because I want to implement a high-frequency controller for my robot and the native USB protocol seems better suited to my needs.

Hello,

I was finally able to make it work :slight_smile:

Here is what I did in case someone else is interested

First define your data structure in Firmware/fibre-cpp/protocol.hpp:

typedef struct {
float p0;
float v0;
float t0;
float p1;
float v1;
float t1;
} dual_axis_data;

Still in protocole.hpp define the encode and decode functions following this example:

template<> struct Codec<dual_axis_data> {
    static std::optional<dual_axis_data> decode(cbufptr_t* buffer) {
        // Decode each float one by one. 
        std::optional<float> p0 = Codec<float>::decode(buffer);
        std::optional<float> v0 = Codec<float>::decode(buffer);
        std::optional<float> t0 = Codec<float>::decode(buffer);
        std::optional<float> p1 = Codec<float>::decode(buffer);
        std::optional<float> v1 = Codec<float>::decode(buffer);
        std::optional<float> t1 = Codec<float>::decode(buffer);

        bool has_vals = p0.has_value() && v0.has_value() && t0.has_value() && p1.has_value() && v1.has_value() && t1.has_value();
        
        return has_vals ? std::make_optional(dual_axis_data{*p0,*v0,*t0,*p1,*v1,*t1}) : std::nullopt;
    }

    static bool encode(dual_axis_data value, bufptr_t* buffer) {
        // Sequentially encode all 6 floats.
        return Codec<float>::encode(value.p0, buffer)
            && Codec<float>::encode(value.v0, buffer)
            && Codec<float>::encode(value.t0, buffer)
            && Codec<float>::encode(value.p1, buffer)
            && Codec<float>::encode(value.v1, buffer)
            && Codec<float>::encode(value.t1, buffer);
    }
};

Once this is done, you can add the attribute in the Firmware/odrive-interface.yaml:

ODrive:
    attributes:
        dual_axis_sensor_data: readonly dual_axis_data

I added it to the Odrive attributes. It does not have to be readonly as we implemented the decode function but I haven’t tried writing data yet.

I then added the attribute to Firmware/MotorControl/odrive_main.h :

dual_axis_data dual_axis_sensor_data_;
  
void update_sensor_estimates(){

    float p0 = axes[0].encoder_.pos_estimate_.any().value_or(0.0f);
    float v0 = axes[0].encoder_.vel_estimate_.any().value_or(0.0f);
    float t0 = axes[0].controller_.torque_output_.any().value_or(0.0f);
    float p1 = axes[1].encoder_.pos_estimate_.any().value_or(0.0f);
    float v1 = axes[1].encoder_.vel_estimate_.any().value_or(0.0f);
    float t1 = axes[1].controller_.torque_output_.any().value_or(0.0f);

    dual_axis_sensor_data_.p0 = p0;
    dual_axis_sensor_data_.v0 = v0;
    dual_axis_sensor_data_.t0 = t0;
    dual_axis_sensor_data_.p1 = p1;
    dual_axis_sensor_data_.v1 = v1;
    dual_axis_sensor_data_.t1 = t1;
}

I call update_sensor_estimates in Firmware/MotorControl/encoder.cpp to update the values:

pos_estimate_ = pos_estimate_counts_ / (float)config_.cpr;
vel_estimate_ = vel_estimate_counts_ / (float)config_.cpr;

odrv.update_sensor_estimates(); // added this line

You then need to update tools/fibre-tools/interface_generator.py:

value_types = OrderedDict({
    'bool': {'builtin': True, 'fullname': 'bool', 'name': 'bool', 'c_name': 'bool', 'py_type': 'bool'},
    'float32': {'builtin': True, 'fullname': 'float32', 'name': 'float32', 'c_name': 'float', 'py_type': 'float'},
    'uint8': {'builtin': True, 'fullname': 'uint8', 'name': 'uint8', 'c_name': 'uint8_t', 'py_type': 'int'},
    'uint16': {'builtin': True, 'fullname': 'uint16', 'name': 'uint16', 'c_name': 'uint16_t', 'py_type': 'int'},
    'uint32': {'builtin': True, 'fullname': 'uint32', 'name': 'uint32', 'c_name': 'uint32_t', 'py_type': 'int'},
    'uint64': {'builtin': True, 'fullname': 'uint64', 'name': 'uint64', 'c_name': 'uint64_t', 'py_type': 'int'},
    'int8': {'builtin': True, 'fullname': 'int8', 'name': 'int8', 'c_name': 'int8_t', 'py_type': 'int'},
    'int16': {'builtin': True, 'fullname': 'int16', 'name': 'int16', 'c_name': 'int16_t', 'py_type': 'int'},
    'int32': {'builtin': True, 'fullname': 'int32', 'name': 'int32', 'c_name': 'int32_t', 'py_type': 'int'},
    'int64': {'builtin': True, 'fullname': 'int64', 'name': 'int64', 'c_name': 'int64_t', 'py_type': 'int'},
    'endpoint_ref': {'builtin': True, 'fullname': 'endpoint_ref', 'name': 'endpoint_ref', 'c_name': 'endpoint_ref_t', 'py_type': '[not implemented]'},
    'dual_axis_data': {'builtin': True, 'fullname': 'dual_axis_data', 'name': 'dual_axis_data', 'c_name': 'dual_axis_data', 'py_type': '[not implemented]'},
})

As well as tools/odrive/pyfibre/fibre/libfibre.py:

codecs = {
    'int8': StructCodec("<b", int),
    'uint8': StructCodec("<B", int),
    'int16': StructCodec("<h", int),
    'uint16': StructCodec("<H", int),
    'int32': StructCodec("<i", int),
    'uint32': StructCodec("<I", int),
    'int64': StructCodec("<q", int),
    'uint64': StructCodec("<Q", int),
    'bool': StructCodec("<?", bool),
    'float': StructCodec("<f", float),
    'object_ref': ObjectPtrCodec(),
    'dual_axis_data': StructCodec("<ffffff",list)
}

And finally Firmware/fibre-cpp/legacy_object_client.cpp:

std::unordered_map<std::string, size_t> codecs = {
    {"bool", 1},
    {"int8", 1},
    {"uint8", 1},
    {"int16", 2},
    {"uint16", 2},
    {"int32", 4},
    {"uint32", 4},
    {"int64", 8},
    {"uint64", 8},
    {"float", 4},
    {"endpoint_ref", 4},
    {"dual_axis_data", 24}
};

For everything to compile properly I had to change Firmware/tup.config as well:

# Copy this file to tup.config and adapt it to your needs
# make sure this fits your board
CONFIG_BOARD_VERSION=v3.6-56V
CONFIG_DEBUG=true
CONFIG_DOCTEST=false
CONFIG_USE_LTO=false

CONFIG_BUILD_LIBFIBRE=true
CONFIG_CC=g++

# Path to the ARM compiler /bin folder (optional)
#CONFIG_ARM_COMPILER_PATH=C:/Tools/ARM/9-2019-q4-major/bin

# Uncomment this to error on compilation warnings
#CONFIG_STRICT=true

Be aware that once the firmware is flashed, you’ll need to use odrivetool in the tools folder.

Hope someone finds this useful :smiley:

Super cool, thanks a ton for sharing!