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