Skip to content

Machine week

General description

Our machine is a Kinetic sand table. It's described on our group page: https://fabacademy.org/2026/labs/ulb/group-assignments/machine-week/

Personal contribution

I was responsible for the electronics and the low level code.

We decided to use a RP2040 to control our sand table.

First step: check we have all electronic components in our fab lab

I found:

Fabio has switches in his lab that we can use as end-switches

PCB requirements

I started by counting the needed pins, to know if a Xiao module will do.

The Pololu module has 3 inputs that must be set externally:

  • DIR sets the rotation direction (clockwise or counter-clockwise)
  • STEP: a rising edge on this pin tells the motor to take a step
  • nSLEEP puts the driver in sleep mode. It's active low.

nSLEEP would be handy to save power when the table is idle.
It could be shared by both modules, as both motors must always be active together.

Needed pins:

  • STEP and DIR for each driver: 4 pins
  • nSLEEP, shared by both driver: 1 pin
  • 1 end-switch on each axis, for homing procedure: 2 pins

Optional pins:

  • M0 and M1 driver signals will allow us to choose the micro-stepping mode. They can be shared by both drivers: 2 pins
  • Control for a WS2812 LED strip: 1 pin

That gives a total of 7 to 10 pins.
The Xiao RP2040 module has 11 GPIO pins => 👍️.

Image title

Motor connections verification

The stepper motor is bipolar and comes with its own cable that has a 4 pin header.
One simple question is "Does its pin order matches the driver module's one ?"
I started by connecting the Xiao, a stepper driver module and a stepper motor to check the connections.
I wanted to be sure of the phase order for the motor.

I wrote a simple test code in microPython to answer this question:

  • it sets DIR to 0
  • it disables nSLEEP
  • it outputs a 50% PWM signal on STEP
stepper-test1-1.py
# machine week stepper connections test
from machine import Pin, PWM

# set P26 as output and its state to 0 (direction is not important for this test)
DIR = Pin(26, Pin.OUT)
DIR.off()
# set P28 as output and its state to 1 to activate the driver (nSLEEP is active low)
nSLEEP = Pin(28, Pin.OUT)
nSLEEP.on()
# create a PWM object on pin P27, set freq = 5 kHz and duty cycle = 50%
pwm = PWM(27, freq=5000, duty_u16=32768)                

while True:
    pass

I connected the motor pins in the "natural" order.
By "natural", I mean that the tracks won't cross each other on the PCB.

The motor should turn smoothly at constant speed.
If the motor doesn't move, there must be a problem in the signals generation.
If the movement is "jerky", it means that the phase order is not correct.

The test was successful.
That means that the PCB routing will be easier !

Schematics

Sand Table electronics schematics

The PCB connects the Xiao module to the driver modules.
It has connectors for the end-switches and the NeoPixel.
There is another connector for an external power supply for the motor voltage.

PCB design

Routing

I routed the PCB:

First PCB routing

Fabio pointed out that the connectors for the driver modules were through-hole.
It means that they would be on the other side of the PCB, or that I would have to solder them on the top side.

We decided to change for SMD connectors.
I would have to design a new footprint for the Pololu module.
To avoid that (and to save time), I added connectors to the schematics, connected "in parallel" with the modules.
Next, I imported them in the PCB, place them on top of the Pololu module and delete the modules:

SMD PCB routing

Production

Engraving went OK, thanks to the training from previous weeks.

Engraving the PCB

Next, I soldered the components:

soldered-PCB
populated-PCB

Only noticeable incident is that our tweezer broke and I had to repair it, with Patrik's help.
The solder point that connects both pieces broke. We replace it with 2 screws:

repaired tweezer

Testing

First, I checked for short-circuit between nets and found none.

Next, I connect our lab power supply to verify that the PCB doesn't draw too much current.
Test successful

Finally, I connected the Xiao module to my PC and upload a modified version of my stepper test code:

# machine week stepper test with wire connections
import time
from machine import Pin, PWM

DIR0 = Pin(27, Pin.OUT)
DIR0.off()
DIR1 = Pin(28, Pin.OUT)
DIR1.off()
nSLEEP = Pin(2, Pin.OUT)
nSLEEP.on()
pwm0 = PWM(29, freq=500, duty_u16=0)                
pwm1 = PWM(6, freq=500, duty_u16=0)                

print("test motor A")
pwm0.duty_u16(32768)
time.sleep(1)
pwm0.duty_u16(0)

time.sleep(0.1)

print("test motor B")
pwm1.duty_u16(32768)
time.sleep(1)
pwm1.duty_u16(0)

nSLEEP.off()
print("done")

This program makes each motor turn for 1 second, one after the other.

Motor B did turn, but not motor A.

I did several tests to pinpoint the problem:

  • I switched the stepper motors. It's always the motor identified as "motor A" by the Xiao that fails.
    It means that the problem is on the PCB.
  • Next, I switched the driver module, to check if both were OK. They are.
  • I remove the motor A driver and connected a logic analyzer to check the command signals generated by the RP2040.
    They were as expected.
  • Finally, I reconnected the driver and used an oscilloscope to visualize the voltages on its pin. I found that the supply voltage (9V) was missing.
    It appears that I forgot to solder this pin.

After soldering it, both motors turn, but one quicker than the other.

I didn't solder the wires that connect M0 and M1 signal, because we don't plan to use it at first.
It means that only motor A driver has its M0 and M1 pins connected to the Xiao.

By default, M0 should be floating and M1 should be 0.
M1 is pulled down by the Pololu module.
I thought that the RP2040 pins will be floating by default, but I tried to to set the Xiao's M0 pin as an input in the code.
It solved the problem.

PIO coding

The goal is to be able to control 2 steppers synchronously, using the RP2040's PIO.

Step signal generation

I started with a code to control a single stepper with a PIO's state machine.
It is based on the code I made during the Outputs week (that was designed for another driver).

PIO stepper code
import rp2
from machine import Pin

@rp2.asm_pio( set_init=(rp2.PIO.OUT_LOW) )
def step():
    wrap_target()
    pull()                  # pull a value from the RX-FIFO (fed by the DMA) to OSR
    out(isr, 16)            # copy the OSR's 16 lsb to ISR (step duration)
    out(y, 16)              # copy the OSR's 16 msb to y (nb. of steps)
    label("step")
    set(pins, 1)
    mov(x, isr)             # initialize the duration counter
    set(pins, 0)
    label("counting")
    jmp(x_dec, "counting")
    jmp(y_dec, "step")
    wrap()
Stepper control
# creates the state machine object and activates it
sm = rp2.StateMachine(0, step, set_base=Pin(27), freq=10000)
sm.active(1)
DMA configuration
# DMA
data = [4, 20]
dataLen = len(data)
byteData = []
for i in range (dataLen):
    byteData.append( data[i]%256 )
    byteData.append( data[i]//256 )
byteData = bytearray(byteData)

# creates the DMA channel
d = rp2.DMA()
# DMA packet configuration
c = d.pack_ctrl(size=2,             # Transfer bytes
                inc_write=False,    # don't increment the write address
                treq_sel=0)         # transfer is initiated by the state machine 0
# DMA channel configuration
d.config( read=byteData,    # data source is our buffer
        write=sm,           # data destination is our state machine RX FIFO
        count=dataLen//2,   # number of data to send
        ctrl=c,             # packet config
        trigger=False)      # transfer doesn't start now
# DMA transfer start
d.active(1)
# Wait for data transfer to end
while d.active():
    pass

Results:

data = [4, 20]:

5 pulses (200 µs), period 2.5 ms

data = [4, 10]:

5 pulses (200 µs), period 1.5 ms

data = [4, 0]:

5 pulses (200 µs), period 0.5 ms

SM clock period = 100 µs

=> step duration = (0.5 + X*0.1) ms = (5 + X) clock period

pio-1stepper-2.py

Next, I improve it by using the sideset capability of the PIO's instructions.
I also added some data encoding to ease sending 16-bit values in the DMA channel.

pio-2_steppers-1.py

Time to try controlling two steppers.

The code configure two state machines to execute the same code.
Each of them has its own DMA channel.
It works, but there is a 120 µs delay between the output signals. It can be caused by the execution time of the microPython program or by the clock speed of the state machines: a clock cycle is 100 µs.

pio-2_steppers-2.py

Anyway, I still have to generate the DIR signals. I didn't find an easy way to do it within the STEP generation code.

Hence, I decided to use a third state machine to generate them and to synchronize the STEP signals generation, using the PIO IRQs.

Day 5: Assembly ?

Early morning problem

I made the calculations to transform steps in turns, then in distance:

  • motor has 200 steps/turn
  • By default, pololu module are in 1/4 step mode => 1 turn = 800 steps

I tested it with my motor test code, but the motor didn't turn.
I checked the PCB and fond that the RP2040 was (very) hot.
I looked for short-circuit between all its pins (with a multimeter) and found none.
Then, I try to connect the Xiao alone to my computer, it became hot again.
The problem was with the Xiao. I soldered sockets on a new one and tried my code again.

It worked.

I don't know what caused the Xiao failure.

The test was conclusive: I asked 8000 steps and the motor pulley made 10 turns.

In my current code (pio-class_Steppers-1.py), I hav no way to tell when the movement ends. I have to add that in my state machines.

mechanical debug

Fabio and I did some tests on the coreXY structure

The L-pieces could use some modifications:

  • the 3 holes for the wheel screws is too small, we had to drill them up to 5 mm diameter
  • a spacer has to be added between the wheel and the piece. it could be included in the L-piece
  • The moving bar should be a bit smaller to avoid touching the structure

One of the feet broke. The walls of the pocket for the aluminium beam are too thin.

Elodie was homeworking today, we send her the modifications to make on the 3D models.

Fabio started to design pieces to attach the end-switches to the structure.

I continue writing the Python code.

New code features

The actual code misses several features:

  • the main code can start a movement, but has no way to know when it finished
  • nSLEEP, M0 and M1 has to be controlled properly
  • The end-switches must be implemented.

I'll start with the movement end detection.

End of movement detection

I decided to use PIO's IRQ again.

  • The step generation state machines will set an IRQ flag when their current sequence ended
  • The main state machine will wait for these iRQ flag to be set before pushing a data in its Rx FIFO
  • The main loop can check if there is a new data in the main state machine FIFO to know if the movement has ended.
    Don't forget to read the data to empty the FIFO.

My implementation (pio-coreXY-1.py) did not work properly. Th

    def isMoving(self):
        if self.sm2.rx_fifo() > 0:
            self.sm2.get()
            print("stopped")
            return False
        else:
            return True
i = 0
while table.isMoving():
    i = i+1
    time.sleep(0.01)

solder 2 switches

add a new function test it, Y always pressed, voltage = 2.74V ?? It appears that on the Xiao, pins P3 and P4 are not in order.

Day 6: XY movements

Next step will be to send commands on motors based on XY coordinates

isMoving don't work again !!

Let's do the math

Our main source for the coreXY is https://corexy.com/theory.html.

We have: $$$ dA = dx + dy dB = dx - dy $$$

where

  • dA, dB are the distance of motor A and B
def moveXY(self, dx, dy):
    dA = dx + dy
    dB = dx - dy
    l = sqrt(dx*dx + dy*dy)
    T = l / self.nomSpeed
    nA = int(dA * self.stepPerMeter)
    nB = int(dB * self.stepPerMeter)
    if nA != 0:
        tA = abs(int(self.freq*T/nA))
    else:
        tA = 0
    if nB !=0:
        tB = abs(int(self.freq*T/nB))
    else:
        tB = 0
    print(nA, tA, nB, tB)
    self.moveStep(nA, tA, nB, tB)
    time.sleep(T)

Day 7

Testing the homing procedure OK

absolute moves

Moves are not OK. However, they are based on moveRelXY(). Motor A doesn't move.

After some trials, we comment out the homing to save time. It solves the problem...

After some tests, it seems that making a 45° diagonal causes the problem.

It strikes me that, in that case, one motor doesn't move. 0 steps doesn't seems to be OK.

now blocked after 5 moves: push wasn't read by moverel => remove the push and make homing moves with moveRel Add speed to moverel

Small python script to test communication => OK