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/

Tasks assignment

Here is how we assigned tasks to evreyone:

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 wanted to be sure of the phase order for the motor.

I made a simple test to answer this question:

I started by connecting the Xiao, a stepper driver module and a stepper motor to check the connections:

  • RP2040's P26 pin is connected to Pololu's DIR pin.
  • RP2040's P28 pin is connected to Pololu's nSLEEP pin.
  • RP2040's P27 pin is connected to Pololu's STEP pin.
  • Stepper's pins are connected to Pololu's outputs.

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

Next, I wrote a simple test code in microPython:

  • 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

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 for PCB
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 set the Xiao's M0 pin as an input in the code.
It solved the problem.

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).

Col

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()

Col

This code drives one of the STEP outputs, with this sequence:

  • line 7: wait to receive a 32-bit data from Tx FIFO. The lower 16 bits are the pulse duration. The upper 16 bits are the number of pulses to generate.
  • line 8: copy the lower 16 bits in the ISR register. ISR'll save the step duration
  • line 9: copy the upper 16 bits in the Y register. Y is the pulse counter The code has 2 nested loops:
  • "step" loop (lines 10-16) counts the number of pulses by decrementing Y.
    • it sets the pin to 1, copy ISR in X. X is the duration counter
    • it sets the pin to 0; The resulting pulse is short, but long enough to trigger the driver
  • "counting" loop (line 14-15) is an empty loop, as its goal is to create a delay

In the main Python code, this snippet instantiates a PIO state machine with PIO code.
Here, it uses SM0, connects it to P27 pin and runs it at 10 kHz. The second line enables the state machine.

Stepper control
1
2
3
# creates the state machine object and activates it
sm = rp2.StateMachine(0, step, set_base=Pin(27), freq=10000)
sm.active(1)

Col

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 32-bit words
                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

Col

This code configures the DMA channel that feed data to the state machine.
Here, the first data (4) is the number of pulses and the second one (20) is the pulse duration, in state machine's period.

  • lines 2-8 transforms the raw data in a formatted bytearray, compatible with the DMA.
  • line 11 creates the DMA channel
  • lines 13-21 configures the data transfer
  • line 23 starts the DMA transfer
  • lines 24-25: the code waits for the transfer to end

test results

I tested this code in the Wokwi simulator:

The logic analyzer shows the output pin chronogram:

I tested it with different data:

data Pulse count Period
[4, 20] 5 2.5 ms
[4, 10] 5 1.5 ms
[4, 0] 5 0.5 ms

These are the expected results:

  • The "step" loop has 5 instructions (7 lines of code, but label are not an instruction). That's the minimum pulse duration.
  • The "counting" loop has only one instruction
  • The state machine's clock period is 100 µs (1/10 kHz)

Hence, step duration = (0.5 + X*0.1) ms = (5 + X) clock period.

There are one more pulses than the first data value, because the "step" loop counts down to zero included.

Code improvement

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-1stepper-2.py
# improved version of pio-stepper:
# using side-set option removes 2 instructions in the PIO code
# It reduces the minimal duration for 1 step:
#   step period = 3 + X clock cycles (instead of 5 + X)

import rp2
from machine import Pin

@rp2.asm_pio( sideset_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")
    mov(x, isr).side(1)             # initialize the duration counter
    label("counting")
    jmp(x_dec, "counting").side(0)
    jmp(y_dec, "step")
    wrap()

pioId = 0       # pio module id (0-1)
smId = 0        # state machine id (0-3)
# creates the state machine object and activates it
sm = rp2.StateMachine(smId+4*pioId, step, sideset_base=Pin(2), freq=10000)
sm.active(1)

# DMA
data = [2, 1]
dataLen = len(data)
byteData = []
for i in range (dataLen):
    byteData.append( data[i]%256 )
    byteData.append( data[i]//256 )
byteData = bytearray(byteData)

dataReqIdx = (pioId << 3) + smId            # DMA request id for our state machine
d = rp2.DMA()                               # creates the DMA channel
c = d.pack_ctrl(size=2,                 # Transfer bytes
                inc_write=False,        # don't increment the write address
                treq_sel=dataReqIdx)    # transfer is initiated by the state machine
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,
    trigger=False)
d.active(1)

Controlling two steppers

Time to try controlling two steppers.

The code configures two state machines to execute the same code.
Each of them has its own DMA channel.

stepperInit() instantiates a PIO state machine to generate the STEP signal. It also configures a DMA channel to feed this state machine.
It returns this DMA channel's object.

stepperMoveConfig() takes the desired number of steps and their duration. It formats these data and stores them in the DMA channel.

This prepares both state machines for the desired moves, but it doesn't start the move.

The main code instantiates two state machines and configures a move for each of them. Then, it activates both DMA channels, back-to-back, so that they are as synchronized as possible.

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-1.py
# First test controlling 2 steppers
# Both DMA channels are activated sequentially, trying to minimize the delay between both pulse trains.
# However, this delay is 119 µs  (by looking at output pins on a logic analyzer). 

import rp2
from machine import Pin

@rp2.asm_pio( sideset_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")
    mov(x, isr).side(1)             # initialize the duration counter
    label("counting")
    jmp(x_dec, "counting").side(0)
    jmp(y_dec, "step")
    wrap()

def stepperInit(smId, pin):
    """ Configure a PIO state machine to control a stepper and creates the associated DMA channel
        smID should be in the range (0-3) 
        Returns the DMA object needed for the other functions """
    # PIO init
    pioId = 0       # pio module id (0-1)
    # creates the state machine objects and activates them
    sm = rp2.StateMachine(smId+4*pioId, step, sideset_base=Pin(pin), freq=10000)
    sm.active(1)
    # DMA init
    d = rp2.DMA()                                   # creates the DMA channel
    c = d.pack_ctrl(size = 2,                       # Transfer 32-bit words
                    inc_write = False,              # don't increment the write address
                    treq_sel = (pioId << 3) + smId) # transfer is initiated by the state machine
    d.config(
        write=sm,               # data destination is our state machine RX FIFO
        ctrl=c,
        trigger=False)
    return d


def stepperMoveConfig(d, stepNb, stepDuration):
    """ Configure the DMA channel with the given data.
          - d is the DMA channel to configure
          - stepNb is the number of steps to move
          - stepDuration is the duration of one step in 1/10th of ms """
    stepNb = stepNb - 1
    stepDuration = stepDuration - 3
    byteData = bytearray( [ stepNb%256, stepNb//256, stepDuration%256, stepDuration//256 ] )
    d.config( read=byteData, count=1 )

# Main code
###########
d0 = stepperInit(0, 2)
stepperMoveConfig(d0, 5, 10)

d1 = stepperInit(1, 27)
stepperMoveConfig(d1, 5, 10)

d0.active(1)
d1.active(1)

Controlling the directions

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.

I added a new PIO code:

start()
@rp2.asm_pio( set_init=(rp2.PIO.OUT_LOW) )
def start():
    wrap_target()
    pull()              # pull a value from the RX-FIFO (fed by the DMA) to OSR
    out(pins, 2)        # set the DIR pins state
    irq(0)
    irq(1)
    wrap()

Its purpose is to generate the DIR signals for the steppers and to trigger the two other state machines.

As step(), it waits to receive a data from its Tx FIFO. This data is the state of the DIR pins.
The code sets the pins and activates two IRQs to trigger the other state machines.

Hence, I also had to modify step().
In fact, I had to duplicate it, as each state machine has its own IRQ.

step0()
@rp2.asm_pio( sideset_init=(rp2.PIO.OUT_LOW) )
def step0():
    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)
    wait(1, irq, 0)         # wait IRQ 0 and reset it
    label("step")
    mov(x, isr).side(1)             # initialize the duration counter
    label("counting")
    jmp(x_dec, "counting").side(0) [4]
    jmp(y_dec, "step")
    wrap()

Otherwise, I just had to had line20. It tells the state machine to wait for IRQ0 to be raised. Then, it resets the IRQ and resumes its execution.

I used this test code to configure the three state machines and execute two movements.

test code
d0 = stepperInit(0, 2)
d1 = stepperInit(1, 27)
smStart = rp2.StateMachine(2, start, set_base=Pin(1), freq=10000000)
smStart.active(1)

stepperMoveConfig(d0, 5, 10)
stepperMoveConfig(d1, 5, 10)
smStart.put(0)
time.sleep(0.1)
stepperMoveConfig(d0, 5, 10)
stepperMoveConfig(d1, 5, 10)
smStart.put(2)

It works as intended.

The mechanical structure is also ready. Let's try to put everything together tomorrow!

Assembly test

Early morning problem

My code measures motors' movement in steps. We need to translate that in an actual distance.

First step (no pun intended), is to transform steps into turns. We know that

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

I add it with my motor test code, but the motor didn't turn.
I checked the PCB and found 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.

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 are 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.

step0()
@rp2.asm_pio( sideset_init=(rp2.PIO.OUT_LOW) )
def step0():
    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)
    wait(1, irq, 0)
    label("step")
    mov(x, isr).side(1)             # initialize the duration counter
    label("counting")
    jmp(x_dec, "counting").side(0)
    jmp(y_dec, "step")
    irq(2)
    wrap()

The start state machine will wait for these IRQ flags to be set before pushing a data in its Rx FIFO

start()
@rp2.asm_pio( out_init=(rp2.PIO.OUT_LOW, rp2.PIO.OUT_LOW) )
def start():
    wrap_target()
    pull()         # pull a value from the RX-FIFO (fed by the DMA) to OSR
    out(pins, 32)
    irq(0)
    irq(1)
    wait(1, irq, 2)
    wait(1, irq, 3)
    set(isr,1)
    push()
    wrap()

I wrote a new function to check if there is a new data in the start state machine's FIFO to know if the movement has ended (Don't forget to read the data to empty the FIFO).

def isMoving(self):
    if self.sm2.rx_fifo() > 0:
        self.sm2.get()
        print("stopped")
        return False
    else:
        return True

The main code use this function to poll the end of movement.

stepperMoveConfig(d0, 5, 10)
stepperMoveConfig(d1, 5, 10)
smStart.put(0)
while isMoving():
    pass
stepperMoveConfig(d0, 5, 10)
stepperMoveConfig(d1, 5, 10)
smStart.put(2)

This first implementation didn't work properly: the code never exit the while loop.

After some tests, I found out that it works when I had a small delay in the while loop:

stepperMoveConfig(d0, 5, 10)
stepperMoveConfig(d1, 5, 10)
smStart.put(0)
while isMoving():
    time.sleep(0.01)
stepperMoveConfig(d0, 5, 10)
stepperMoveConfig(d1, 5, 10)
smStart.put(2)

It seems that checking the Rx FIFO continuously causes the problem.
I don't exactly understand why, but as long as I have a workaround...

We are now able to make the magnet move!!

End-switches

Fabio added the two end-switches to the mechanical structure while I wrote the code to use them.

First, I added two lines at the beginning of the main code to configure the pins connected to the end-switches:

endX = Pin(2, Pin.IN)
endY = Pin(3, Pin.IN)

Then, I wrote functions to read their status:

def endXPressed():
    return endX.value() == 0

def endYPressed():
    return endY.value() == 0

We connect the switches to the PCB to test them.
Y end-switch was detected as always pressed. I measured the input voltage and find 2.74V ??

After checking the connections and some confusing time, I realized that, on the Xiao, the pin order is P1, P2, P4 and P3. Hence, the Y end-switch is connected to P4 and not P3.

Code restructuration

As my code structure is now clear, it's time to clean it. I decided to restructure it as a class.
My goal is to have a "high level" object abstracting the main program from the hardware "low level" functions.

Block diagram

The main program runs on the RP2040 core. It controls directly the nSLEEP and M0 pins.

It controls the two stepper motors through three PIO state machines.

State machine 1 and 2 codes are based on the previous one: they receive data from a DMA channel and drives their output pin accordingly.
The main change is that they wait for an IRQ to be set before starting their sequence.
When the sequence ends, they sets another IRQ to signal it. their code is explained in the next section.

State machine 2 runs the start() code (see below). The main program send it the direction of the motors.
It sets DIR0 and DIR1 accordingly an tells the other state machines to start their sequence by setting IRQs.

Steppers class

I put all the stepper related code in a class, to create an abstraction layer between the hardware and the main program.

The __init__() function instantiates the three state machines and configures the two DMA channels.
It also configures the other pins.

__init__()
    def __init__(self, dir0Pin=27, step0Pin=29, step1Pin=6, endXPin=2, endYPin=4, M0Pin=7):
        # PIO init
        self.freq = 50000
        self.sm0 = rp2.StateMachine(0, step0, sideset_base=Pin(step0Pin), freq=self.freq)
        self.sm1 = rp2.StateMachine(1, step1, sideset_base=Pin(step1Pin), freq=self.freq)
        self.sm0.active(1)
        self.sm1.active(1)
        self.sm2 = rp2.StateMachine(2, start, out_base=Pin(dir0Pin), freq=10000000)
        self.sm2.active(1) 
        # DMA init
        self.d0 = rp2.DMA()                     # creates the DMA channel
        c0 = self.d0.pack_ctrl( size = 2,       # Transfer 32-bit words
                          inc_write = False,    # don't increment the write address
                          treq_sel = 0 )        # transfer is initiated by the state machine
        self.d0.config( write = self.sm0,       # data destination is our state machine RX FIFO
                        ctrl = c0,
                        trigger = False )
        self.d1 = rp2.DMA()                     # creates the DMA channel
        c1 = self.d1.pack_ctrl( size = 2,       # Transfer 32-bit words
                          inc_write = False,    # don't increment the write address
                          treq_sel = 1 )        # transfer is initiated by the state machine
        self.d1.config( write = self.sm1,       # data destination is our state machine RX FIFO
                        ctrl = c1,
                        trigger = False )
        # end-switches pin init
        self.endX = Pin(endXPin, Pin.IN)
        self.endY = Pin(endYPin, Pin.IN)
        # Microstepping
        M0 = Pin(M0Pin, Pin.IN)
        M0.off()

The moveEncode() function is an utility function that formats the data for the DMA channels.

moveEncode()
    def moveEncode(self, stepNb, stepDuration):
        stepDuration = stepDuration - 3
        if stepNb >= 0:
            stepNb = stepNb - 1
            data = bytearray( [ stepNb%256, stepNb//256, stepDuration%256, stepDuration//256 ] )
            dir = 0
        else:
            stepNb = -stepNb - 1
            data = bytearray( [ stepNb%256, stepNb//256, stepDuration%256, stepDuration//256 ] )
            dir = 1
        return dir, data       

The moveStep() function receives the data for a movement (steps number and duration for both motors), configures the DMA channels and triggers the start state machine by sending it the motors' direction.

moveStep()
    def moveStep(self, stepNb0, stepDuration0, stepNb1, stepDuration1):
        dir0, data0 = self.moveEncode(stepNb0, stepDuration0)
        self.d0.config( read=data0, count=1, trigger=True )
        dir = dir0
        dir1, data1 = self.moveEncode(stepNb1, stepDuration1)
        self.d1.config( read=data1, count=1, trigger=True )
        dir = dir + 2*dir1
        self.sm2.put(dir)

The isMoving() function checks if the motors are moving or not. It allows the main code to know when the current movement ends.

isMoving()
    def isMoving(self):
        if self.sm2.rx_fifo() > 0:
            self.sm2.get()
            print("stopped")
            return False
        else:
            time.sleep(0.001)
            return True

XY movements

Next step is to control the motors based on XY coordinates instead of steps

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

Here is the resulting function:

    def moveRelXY(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
        self.moveStep(nA, tA, nB, tB)
        time.sleep(T)
        self.x += dx
        self.y += dy

When I tested it, The problem with isMoving()happened again.

I made some research in the microPython documentation and RP2040 forums, but couldn't find an explanation for this bug.

I decided to avoid it by replacing it with a simple time.sleep() call. As I can compute the movement duration (T), it's a totally acceptable solution.

With that bug fixed, we are now able to move the magnet with (dX, dY) commands.

Homing procedure

I added a new function to my class:

    def homing(self):
        while not self.endYPressed():
            self.moveStep(-10, self.slowPer, 10, self.slowPer)
            while self.isMoving():
                pass
        print("Y homed")
        self.moveStep(800, self.nomPer, -800, self.nomPer)
        while self.isMoving():
            pass
        while not self.endXPressed():
            self.moveStep(-10, self.slowPer, -10, self.slowPer)
            while self.isMoving():
                pass
        print("X homed")
        self.moveStep(800, self.nomPer, 800, self.nomPer)
        while self.isMoving():
            pass

It works at the first test!!

Absolute XY movements

The last missing functionality is to be able to move the magnet to an absolute (X,Y) position.

    def moveAbsXY(self, x, y):
        #print(f"Rel. move {x-self.x}, {y-self.y}") 
        self.moveRelXY(x-self.x, y-self.y, self.nomSpeed)
        self.x = x
        self.y = y

It's simple to implement as it's just a matter to call moveRelXY()with the right parameters.

However, when we tested it, the magnet doesn't move as expected. Homing procedure went OK, but the following absolute movement was not right.

After some tests, we found out that, whatever move we ask, motor A doesn't move.

We were confused: it wasn't a hardware problem because homing was OK. After many tests, we decided to comment out the homing to save time. It solves the problem...

It appears quickly that it was the last move of homing() that causes the problem.

After some investigation, I realized that it's a 45° diagonal move. That means that one motor (motor A here) does not turn during this move.

step()function can't handle a zero step move. Indeed, it makes no sense.

In that case, the solution is not to send a command to the state machine controlling the motor that doesn't have to move.

    def moveRelXY(self, dx, dy, speed):
        dA = dx + dy
        dB = dx - dy
        l = sqrt(dx*dx + dy*dy)
        T = l / speed
        nA = int(dA * self.stepPerMm)
        nB = int(dB * self.stepPerMm)
        if nA != 0:
            tA = abs(int(self.freq*T/nA))
        else:
            nA = 1
            tA = 0
        if nB !=0:
            tB = abs(int(self.freq*T/nB))
        else:
            nB = 1
            tB = 0
        self.moveStep(nA, tA, nB, tB)
        time.sleep(T)

It solves the problem... allowing us to encounter the next one:

The five first magnet moves works well, but nothing happens afterwards. This one was easy to spot: As I remove the isMoving() function in moveRelXY(), it doesn't read the data that start() state machine pushes in its Rx FIFO.
After five moves, the FIFO is full, blocking the state machine.

Hence, I removed the push() and modify homing() to use moveRelXY() (to get rid of homing()).

Interface with Fabio's application

Finally, I write a main code that receive commands from Fabio's application

if  __name__=="__main__":
    table = Steppers()
    nSLEEP = Pin(1, Pin.OUT)
    nSLEEP.on()
    table.homing()
    table.x = 0
    table.y = 0
    nSLEEP.off()
    print("Homed")
    while True:
        newX = int(input())
        newY = int(input())
        nSLEEP.on()
        table.moveAbsXY(newX, newY)
        nSLEEP.off()
        print("OK")

It's fairly simple:

  • table is the object to control the steppers
  • nSLEEP is deactivated to enable the motor drivers
  • We start by homing the sand table
  • In the main loop, we wait for a (X,Y) coordinate, then move the magnet accordingly.

nSLEEP is activated between moves to avoid unneeded power consumption.

Useful files

RP2040 final microPython code

Sand Table's electronic schematic

Sand Table's electronic PCB