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?