Need help controlling servo's in Micro Python

My goal is to control two servo’s with a Tiny 2040 running Pimoroni’s custom uf2.
Tiny 2040
I have two Rotary Encoders setup and working.
RGB Encoder Breakout
One will be for Pan and one for Tilt.
Adafruit Mini Pan-Tilt Kit - Assembled with Micro Servos

At the moment I have the two servo’s plugged into an IO Expander.
IO Expander Breakout
The example file runs the servo through its full motion as intended.
pimoroni-pico/micropython/examples/breakout_ioexpander/servo.py at main · pimoroni/pimoroni-pico

What I want to do is have the servo move when an encoder is adjusted. I’m lost as to what is going on in that example file? I’m also trying to avoide having to use something like an Inventor for just two servo’s. Seems like over kill to me?

Some explanatory documentation for the IO Expander would really help, here, along with interface documentation for the library. My conceptual model for it is that the microcontroller on the IO Expander does the job of manipulating various pins on behalf of the Pico, and is wired up using I2C, with the library hiding that aspect and providing a hopefully convenient interface.

Anyway, I imagine that you would choose a suitable PWM period and divider as in the example, these presumably being shared by all PWM pins, and then set the mode on the appropriate pins for your servos. When changing the duty cycle on a particular pins for a particular servo, the output method on the BreakoutIOExpander object (ioe in the example) would be used.

When it comes to reading the encoders, it seems like the same microcontroller solution is used but with a different I2C address. I would think that the same library could be used, even though the product page for the encoder points elsewhere. The library does provide things like setup_rotary_encoder and read_rotary_encoder, for instance. So, the example for the ioe-python library could be adapted.

I did some code up to read the encoders.

from pimoroni_i2c import PimoroniI2C
from breakout_encoder import BreakoutEncoder
from pimoroni import RGBLED
led = RGBLED(18, 19, 20)
led.set_rgb(50, 0, 0)

steps_per_rev = 90

i2c = PimoroniI2C(sda=(4), scl=(5))
enc_L = BreakoutEncoder(i2c,0xe)
enc_R = BreakoutEncoder(i2c,0xf)

enc_L.set_brightness(1.0)
enc_R.set_brightness(1.0)
# enc.set_direction(BreakoutEncoder.DIRECTION_CCW)     # Uncomment this to flip the direction


# From CPython Lib/colorsys.py
def hsv_to_rgb(h, s, v):  # noqa: RET503
    if s == 0.0:
        return v, v, v
    i = int(h * 6.0)
    f = (h * 6.0) - i
    p = v * (1.0 - s)
    q = v * (1.0 - s * f)
    t = v * (1.0 - s * (1.0 - f))
    i = i % 6
    if i == 0:
        return v, t, p
    if i == 1:
        return q, v, p
    if i == 2:
        return p, v, t
    if i == 3:
        return p, q, v
    if i == 4:
        return t, p, v
    if i == 5:
        return v, p, q

count_L = 0
count_R = 0

def count_L_changed(count):
    print("Count L: ",count_L, " Count R: ",count_R)
    h = ((count_L % steps_per_rev) * 360.0) / steps_per_rev     # Convert the count to a colour hue
    r, g, b = [int(255 * c) for c in hsv_to_rgb(h / 360.0, 1.0, 1.0)]  # rainbow magic
    enc_L.set_led(r, g, b)

count_L = 0
count_L_changed(count_L)
enc_L.clear_interrupt_flag()

def count_R_changed(count):
    print("Count L: ",count_L, " Count R: ",count_R)
    h = ((count_R % steps_per_rev) * 360.0) / steps_per_rev     # Convert the count to a colour hue
    r, g, b = [int(255 * c) for c in hsv_to_rgb(h / 360.0, 1.0, 1.0)]  # rainbow magic
    enc_R.set_led(r, g, b)

count_R = 0
count_R_changed(count_R)
enc_R.clear_interrupt_flag()

while True:
    if enc_L.get_interrupt_flag():
        count_L = enc_L.read()
        enc_L.clear_interrupt_flag()
        '''
        while count_L < 0:
            count_L += steps_per_rev
        '''
        count_L_changed(count_L)
        
    if enc_R.get_interrupt_flag():
        count_R = enc_R.read()
        enc_R.clear_interrupt_flag()
        '''
        while count_R < 0:
            count_R += steps_per_rev
        '''
        count_R_changed(count_L)        

That gets me this

>>> %Run -c $EDITOR_CONTENT

MPY: soft reboot
Count L:  0  Count R:  0
Count L:  0  Count R:  0
Count L:  1  Count R:  0
Count L:  2  Count R:  0
Count L:  3  Count R:  0
Count L:  2  Count R:  0
Count L:  1  Count R:  0
Count L:  1  Count R:  1
Count L:  1  Count R:  2
Count L:  1  Count R:  3
Count L:  1  Count R:  2
Count L:  1  Count R:  1
Count L:  1  Count R:  0
Count L:  1  Count R:  -1
Count L:  1  Count R:  -2
Count L:  0  Count R:  -2
Count L:  -1  Count R:  -2

0 is the starting centered position, - one way, + the other. I’ll likely limit them to +90 and -90. 180 degree servos.

I also have code to test run the servos.

import time
import math
from pimoroni_i2c import PimoroniI2C
from breakout_ioexpander import BreakoutIOExpander

PINS_BREAKOUT_GARDEN = {"sda": 4, "scl": 5}
PINS_PICO_EXPLORER = {"sda": 20, "scl": 21}

ioe_servo_pin = 1 #pan
#ioe_servo_pin = 2 #tilt

# Settings to produce a 50Hz output from the 24MHz clock.
# 24,000,000 Hz / 8 = 3,000,000 Hz
# 3,000,000 Hz / 60,000 Period = 50 Hz
divider = 8
period = 60000
cycle_time = 5.0
servo_range = 2000.0    # Between 1000 and 2000us (1-2ms)

i2c = PimoroniI2C(**PINS_BREAKOUT_GARDEN)
ioe = BreakoutIOExpander(i2c, address=0x18)

ioe.set_pwm_period(period)
ioe.set_pwm_control(divider)

ioe.set_mode(ioe_servo_pin, BreakoutIOExpander.PIN_PWM)

t = 0

while True:
    s = math.sin((t * math.pi * 2.0) / cycle_time) / 2.0
    servo_us = 1500.0 + (s * servo_range)

    duty_per_microsecond = period / (20 * 1000)    # Default is 3 LSB per microsecond

    duty_cycle = round(servo_us * duty_per_microsecond)
    print("Cycle Time: ", t % cycle_time, ", Pulse: ", servo_us, ", Duty Cycle: ", duty_cycle, sep="")
    ioe.output(ioe_servo_pin, duty_cycle)

    time.sleep(0.02)
    t += 0.02

I just need to decifer what the servo code is actually doing / how it does it?
The servo cycles back and forth from full left to full right when I run it.

It seems that the servo position is being associated with a sine curve with a 5 second period, scaled to a range of -0.5 to 0.5. This effectively gives the motion you’re reporting.

This value is then used to vary the servo_us value across the servo_range, centred on 1500, which yields a range of 500 to 2500. The duty calculations presumably correspond to something written up in the servo datasheet.

Ultimately, the PWM pin is updated using the recalculated duty cycle value.

I would have to look at the datasheet to understand how the servo operates, but perhaps you could use the encoder counts to generate the appropriate value for s, instead of it being taken from a sine curve.

That’s the plan, more or less. I just have to sort out which variable to change. I’ll play around with it tomorrow some time and post back the results.
Thankyou very much for the help so far. =)

Here is some basic code to get you started with a servo.

# SG90 servo BASIC example
# === Tony Goodhew - 14th March 2024 ===

from machine import Pin,PWM
from time import sleep
servo = PWM(16)
servo.freq(50)
minn =1750
maxx = 8191
servo.duty_u16(maxx) # Fully CCW = 0
sleep(1)
servo.duty_u16(minn) # Fully CW = 180 
sleep(1)
mean =int((minn + maxx)/2)
servo.duty_u16(mean) # Mid point = 90
sleep(1)
servo.duty_u16(0)

def set(angle):
    duty = int(maxx - angle/180 * (maxx-minn))
    servo.duty_u16(duty)
    
# Step in 10 deg interval from 0 to 180 degrees
for deg in range(0,181,10):
    set(deg)
    print(deg)
    sleep(0.5)
sleep(1)

set(0) # Zero the pointer
sleep(1)

servo.duty_u16(0)

You may need to adjust the maxx and minn values as all servos are slightly different. You need the arm to turn exactly 180 degrees between the 2 values.

I would use potentiometers rather than rotary encoders - the Expander can handle this as well.

Let me know how you get on.

1 Like

Using potentiometers was my first choice, but they were out of stock.
Wow, thank’s for that code. Just need to get a cup of java to get me going. I’ll post back how it works. =)

Is that the physical pin number or the GP number?

EDIT: Never mind figured it out, its the physical pin number. Its GP(0), pin 16 on a Pico, but pin 0 on the Tiny 2040.
servo = PWM(0)
That worked great, thanks a bunch. =)