Kylo Wen? Kylo Now

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:

  1. Read the PWM signal in from the RX
  2. Convert the 3 values to a number between -100 and +100
  3. 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
  4. 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
  5. Convert back to PWM timings
  6. 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

“Final” build

5 Likes

Yes! Someone else building holonomic drive bots!

I really like the idea you’ve got with the scaling - in my bots, I do all my programming on the Tx (via EdgeTx), so I scaled my inputs by 1/3 so in the case of full ele, ail and rud I get max output. This means going just forward gives me only 33% power (I get around this by having a push button that multiplies the result by 3 for box rushes). With your method, you’ll get full power regardless of direction, which is much better.

As an aside, this prompted me to look up if I could code custom functions in OpenTx-based firmware, and it turns out there is the functionality to do that with Lua scripts! Looks like I have a(nother) project now.

Any plans to make a full combat omni-drive robot?

There are always plans for things, who knows if I’ll ever get around to actually doing it though. :upside_down_face:

Good luck with the custom functions on the Tx! I don’t think mine is fancy enough to do the mixing, which is why I went with an on-board microcontroller.

At some point I’d like to see about tuning the response curves a little, so it’s a bit less twitchy, especially in rotation. Gotta work out how to do some kind of S-curve on the inputs…