From the carcass of the ill-fated Kylo Wen, Spinnerproof’s R&D department has bodged together Kylo Now.
I’ve wanted to do an omni-directional robot for a while, and I figured now was the time.
Phase One - True R&D
I bought a set of mecannum wheels, then got to work with a Raspberry Pi Pico, and the electronics/motors from Kylo Wen. I bodged together a chassis using PVC board and hot glue, then janked up some electronics on a breadboard.
Protype (is it possible to embed short videos/gifs on the forum?)
The electronics are the usual RC receiever and motor controllers, but with the addition of a micro-controller to do the mixing. I went with a Raspberry Pi Pico because a) it’s what I have, and b) I’m more comfortable with Python than C. But this would work just as well with an arduino (or, indeed, any other microcontroller). The Pico reads the PWM signals coming out of the receiver. We’re interested in Throttle, Rudder, and Aileron. In my case it puts translation on the left stick, and rotation on the right stick. This is very familiar to me, having spent many hours playing video games with controllers. In the code I refer to these as Surge, Sway, and Yaw. Surge is translation forwards and backwards, Sway is translation left and right, and Yaw is rotation.
The Pico then mixes those 3 inputs into 4 outputs, one for each motor. The mixing is pretty simple:
- Read the PWM signal in from the RX
- Convert the 3 values to a number between -100 and +100
- Sum those numbers as needed for each motor
i. front left wheel = surge + sway - yaw
ii. front right wheel = surge - sway + yaw
iii. rear left wheel = surge - sway - yaw
iv. rear right wheel = surge + sway + yaw - Scale all the values to within the -100 to +100 range. You have to use the same scale value for all 4 values, so find the one that’s furthest out of range, then scale everything according to that
- Convert back to PWM timings
- Send to the motors.
Here’s the code:
# Copyright © 2023 Sam Hutchins
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the “Software”), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
# THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
from machine import Pin, PWM
import rp2
import time
def main():
led = Pin(25, Pin.OUT)
led.off()
surge_rx = RxChannel(0, 4)
sway_rx = RxChannel(1, 5)
yaw_rx = RxChannel(2, 6)
motors = Motors(0, 1, 2, 3)
time.sleep(1)
led.on()
while True:
surge = surge_rx.get_value()
sway = sway_rx.get_value()
yaw = yaw_rx.get_value()
motors.move(surge, sway, yaw)
class Motors:
def __init__(self, front_left: int, front_right: int, rear_left: int, rear_right: int) -> None:
self.front_left = Motor(Pin(front_left), "left")
self.front_right = Motor(Pin(front_right), "right")
self.rear_left = Motor(Pin(rear_left), "left")
self.rear_right = Motor(Pin(rear_right), "right")
def move(self, surge: int, sway: int, yaw: int) -> None:
fl = surge + sway - yaw
fr = surge - sway + yaw
rl = surge - sway - yaw
rr = surge + sway + yaw
fl, fr, rl, rr = Motors.__normalise((fl, fr, rl, rr))
self.front_left.set_speed(fl)
self.front_right.set_speed(fr)
self.rear_left.set_speed(rl)
self.rear_right.set_speed(rr)
@staticmethod
def __normalise(speeds: Tuple[int]) -> Tuple[int]:
extra_range = max([ Motors.__extra_range(x) for x in speeds ])
if extra_range == 0:
return speeds
else:
return [ Motors.__scale(x, extra_range) for x in speeds ]
@staticmethod
def __extra_range(value: int) -> int:
if -100 <= value and value <= 100:
return 0
else:
return abs(value) - 100
@staticmethod
def __scale(value: int, extra_range: int) -> int:
in_start = -100 - extra_range
in_end = 100 + extra_range
offset: float = (value - in_start) / (in_end - in_start)
return -100 + (offset * 200)
class Motor:
def __init__(self, pin: Pin, side: str) -> None:
self.pwm = PWM(pin)
self.pwm.freq(200)
self.pwm.duty_ns(1500 * 1000)
self.side = side
def set_speed(self, speed: int) -> None:
if speed < -100 or 100 < speed:
speed = 0
if self.side == "right":
speed = speed * -1
offset: float = (speed + 100) / 200
pwm_duty = 1000 + int(offset * 1000)
self.pwm.duty_ns(pwm_duty * 1000)
# Measures the high pulse width of an incoming PWM signal in clock cycles
@rp2.asm_pio()
def read_pwm():
label("start")
mov(x, invert(null))
wait(0, pin, 0)
wait(1, pin, 0)
label("time_high")
jmp(x_dec, "test")
jmp("start") # exceptional case
label("test")
jmp(pin, "time_high")
mov(isr, invert(x))
push(noblock)
irq(rel(0))
class RxChannel:
def __init__(self, channel_num: int, pin_num: int) -> None:
self.pulse_width = None
self.value = 0
self.change = False
statemachine = rp2.StateMachine(channel_num)
pin = Pin(pin_num, Pin.IN)
statemachine.init(read_pwm, in_base=pin, jmp_pin=pin)
statemachine.irq(self.__handle_interrupt)
statemachine.active(1)
def get_value(self) -> int:
if self.change:
pulse_width = int((self.pulse_width * 2) / 125) # convert to ms
pulse_width: int = max(1000, min(pulse_width, 2000))
offset: float = (pulse_width - 1000) / 1000
self.value = -100 + int(offset * 200)
self.change = False
return self.value
def __handle_interrupt(self, statemachine):
self.pulse_width = statemachine.get()
self.change = True
if __name__ == "__main__":
main()
Phase 2 - RoboNerd panic
I’ve only just realised that I am actually free for RoboNerd and, given my sadness in previous years about not having a robot to bring, I thought I should build a slightly more robust version of my earlier prototype
It’s all 3mm HDPE, roughly cut with a jigsaw and eyeballed measurements. I found some old nutstrip I must have bought from BBB ages ago, and had just enough to hold it all together. Everything just plonks inside, and I’d say it’s good enough for RoboNerd. Nothing like hacking together a ‘robot’ in less than a day, eh?
Hopefully you’ll see it at RoboNerd this weekend, but don’t hold me to it