Powered Up Remote Control

Python code and building instructions for the LEGO Technic 6x6 Volvo Articulated Hauler (42114).

Powered Up Remote Control
Image credit: LEGO

Control the Volvo articulated hauler with the Powered Up Remote!


Manual and automatic switching mode

The program for controlling Technic Volvo Articulated Hauler with remote control is a bit more complicated than the other remote control programs because of the 3-gear gearbox, which works both in manual and automatic mode.

Manual gear switching requires not only detection whether the remote button is pressed or not, but also detection of the button press (or release) moment itself. This is realized using the Key class.

The automatic gearbox needs to detect the proper time for switching the gear up or down. This is possible by measuring the speed of XL motor, when it is running. If the speed is systematically very low, the gear is decreased. If the speed is systematically close to maximum, the gear is increased.

To make speed measurement robust to random variations, values obtained from the speed sensor are filtered using simple exponential smoothing. Threshold values of speed, measurement time and smoothing constant are defined by the constants HI_SPEED, LO_SPEED, STABLE_SPEED_TIME and SMOOTHING. If the automatic gearbox does not change gears even if Hauler reaches full speed, HI_SPEED should be decreased. Speed tracking, keeping the current state of gearbox and handling the remote/hub LEDs was implemented in the Gearbox class.

Driving and switching gears

By default, the left controller controls the left/right steering, and the right controller determines the direction of driving. This can be changed easily by setting the constant LEFT_STEER_RIGHT_DRIVE to False.

The gearbox can be used in two modes, as in the original LEGO smartphone app: automatic and manual. You can switch between the modes by pushing the green button on the remote. Automatic is default starting mode, but this can be easily changed by modifying constant INIT_GEARBOX_AUTO.

In the automatic mode, gears are changed when program detects that motor’s speed is too slow or close to maximum speed. The current gear is indicated by the color of the remote’s LED:

  1. Cyan
  2. Blue
  3. magenta

To enable dumper (remote LED: green), press the right red button on the remote. To go back to driving mode, press left red button. If drive is idle for time longer than defined in constant GEAR_RESET_TIMEOUT, the gear is set to 1.

In the manual mode, the gear is decreased by pressing left red button, and increased with right red button. Gearbox positions are indicated by the color of LED:

  1. Yellow
  2. Orange
  3. Red

Sometimes gearbox tends to jam. If the target angle of gear selector is not reached within the time defined by GEAR_SWITCH_TIMEOUT (1.5 sec by default), the automatic gearbox reset is performed. The hub LED changes to red, while the gearbox is recalibrated, which sets the gear to 1.

Program

from pybricks.hubs import TechnicHub
from pybricks.pupdevices import Motor, Remote
from pybricks.parameters import Port, Button, Color
from pybricks.tools import wait

# control buttons swapping
LEFT_STEER_RIGHT_DRIVE = True

# set gearbox to AUTO mode at startup?
INIT_GEARBOX_AUTO = True

# steering settings
STEER_ANGLE = 65
STEER_SPEED = 1000
STEER_HARDNESS = 4


class Gearbox:
    # if speed is stable above, automatic gearbox will increase gear
    HI_SPEED = 1400
    # if speed is stable below, automatic gearbox will decrease gear
    LO_SPEED = 300
    # time [ms] after gearbox switches to 1st gear when drive remains idle
    GEAR_RESET_TIMEOUT = 2000
    # time [ms] of speed stability measurement before automatic gear change
    STABLE_SPEED_TIME = 800
    # time [ms] for normal gear switch
    GEAR_SWITCH_TIMEOUT = 1500
    # speed measurement smoothing factor
    SMOOTHING = 0.05
    # colors of gearbox state indicator LED for automatic (True) and manual (False)
    # [gear 1, gear 2, gear 3, dumper]
    POS_COLOR = {
        True: [Color.CYAN, Color.BLUE, Color.MAGENTA, Color.GREEN],
        False: [Color.ORANGE, Color(h=15, s=100, v=100), Color(h=5, s=100, v=100), Color.GREEN],
    }

    def __init__(self, remote: Remote, hub: TechnicHub, drive: Motor):
        # assign external objects to properties of the class
        self.remote = remote
        self.hub = hub
        self.drive = drive
        # initialize control variables
        self.speed_timer = 0
        self.idle_timer = 0
        self.speed = 0
        # initialize L motor
        self.gearbox = Motor(Port.B)
        self.calibrate()
        # set defaults
        self.last_auto_pos = 0
        self.set_auto(INIT_GEARBOX_AUTO)

    def calibrate(self):
        # calibrate gearbox motor by finding its physical rotation limit;
        # first, move left at full power to handle possible jam in gearbox
        self.gearbox.run_until_stalled(360)
        # second, correct the position
        self.gearbox.run_angle(360, -90)
        # finally move left with small power to avoid twisting 12-axle and measurement error
        stalled_angle = self.gearbox.run_until_stalled(360, duty_limit=10)
        # round to multiple of 90 degrees and subtract angle of physical block (90deg)
        base_angle = 90 * round(stalled_angle / 90) - 90
        # adjust settings of possible motor positions
        self.pos_angle = [p + base_angle for p in [90, 0, -90, -180]]
        self.pos = 0

    def set_position(self, pos):
        # limit positions to range 0,1,2,3
        pos = min(3, max(pos, 0))
        # apply new position, if it is different from the current one
        if self.pos != pos:
            # set remote control light according to mode and position
            self.remote.light.on(self.POS_COLOR[self.auto][pos])
            # stop drive to allow smooth gear change
            self.drive.stop()
            # rotate gearbox to angle that corresponds position
            self.gearbox.run_target(720, target_angle=self.pos_angle[pos], wait=False)
            # control time of gear change, to detect possible position mismatch
            change_time = 0
            while not self.gearbox.control.done() and change_time < self.GEAR_SWITCH_TIMEOUT:
                # measure the switching time
                change_time += 1
                wait(1)
            if change_time == self.GEAR_SWITCH_TIMEOUT:
                # timeout occured - something went wrong, true gearbox position
                # is different than expected - set hub LED to red
                self.hub.light.on(Color.RED)
                # stop switching and recalibrate
                self.gearbox.stop()
                self.calibrate()
                # 1st gear is set
                pos = 0
                self.remote.light.on(self.POS_COLOR[self.auto][pos])
                self.hub.light.on(Color.GREEN)
            # remember last automatic gear
            self.last_auto_pos = self.pos if pos == 3 else pos
            # update gear state variable
            self.pos = pos

    def dumper(self):
        # return whether gearbox is set to drive dumper
        return self.pos == 3

    def set_auto(self, auto):
        # set AUTO/MANUAL mode and update control light
        self.auto = auto
        self.remote.light.on(self.POS_COLOR[self.auto][self.pos])

    def update_auto_gear(self):
        # in AUTO mode changes gear if speed is stable below/above LO_SPEED/HI_SPEED threshold
        if self.auto and not self.dumper():
            speed = self.drive.speed()
            # basic low-pass filtering (exponential smoothing)
            self.speed += self.SMOOTHING * (abs(speed) - self.speed)
            wait(10)
            if self.LO_SPEED < self.speed < self.HI_SPEED:
                # speed in medium range, reset timer
                self.speed_timer = 0
            else:
                # speed out of medium range, increase time of measurement
                self.speed_timer += 10
                if self.speed_timer > self.STABLE_SPEED_TIME:
                    # speed is stable - reset timer
                    self.speed_timer = 0
                    # depending on speed and current position,
                    # return lower, higher or None gear (no change)
                    if self.pos > 0 and self.speed < self.LO_SPEED:
                        self.set_position(self.pos - 1)
                    elif self.pos < 2 and self.speed > self.HI_SPEED:
                        self.set_position(self.pos + 1)

    def idle(self, persists):
        if persists:
            # increase idle time
            wait(1)
            self.idle_timer += 1
            if self.auto and self.idle_timer >= self.GEAR_RESET_TIMEOUT:
                # reset gearbox to lowest gear
                self.idle_timer = 0
                gearbox.set_position(0)
        else:
            self.idle_timer = 0


class Key:
    def __init__(self):
        # variables to store current and previous state of buttons
        self.now_pressed = []
        self.prev_pressed = []

    def update(self, remote):
        # copy list of keys pressed during last update
        self.prev_pressed = list(self.now_pressed)
        # update list of pressed keys
        self.now_pressed = remote.buttons.pressed()

    def pressed(self, key):
        # return whether key is now pressed
        return key in self.now_pressed

    def released(self, key):
        # return keys which were released after last update
        return key in self.prev_pressed and key not in self.now_pressed


def direction(positive, negative):
    # return resultant value of two boolean directions
    return int(bool(positive)) - int(bool(negative))


if __name__ == "__main__":
    CONNECT_FLASHING_TIME = [75] * 5 + [1000]
    hub = TechnicHub()
    # Flashing led while waiting connection as remote do
    hub.light.blink(Color.WHITE, CONNECT_FLASHING_TIME)

    # Connect to the remote.
    remote = Remote()
    print("Remote connected.")

    # Wait for calibration
    hub.light.on(Color.YELLOW)

    # initialize driving motor
    drive = Motor(Port.A)

    # initialize steering motor
    steer = Motor(Port.D)
    kp, ki, _, _, _ = steer.control.pid()
    steer.control.limits(speed=STEER_SPEED)
    steer.control.pid(kp=kp * STEER_HARDNESS, ki=ki * STEER_HARDNESS)

    # initialize gearbox
    gearbox = Gearbox(remote, hub, drive)

    # initialize remote keys
    key = Key()
    if LEFT_STEER_RIGHT_DRIVE:
        BUTTON_DRIVE_FWD, BUTTON_DRIVE_BACK = Button.RIGHT_PLUS, Button.RIGHT_MINUS
        BUTTON_STEER_LEFT, BUTTON_STEER_RIGHT = Button.LEFT_PLUS, Button.LEFT_MINUS
    else:
        BUTTON_DRIVE_FWD, BUTTON_DRIVE_BACK = Button.LEFT_PLUS, Button.LEFT_MINUS
        BUTTON_STEER_LEFT, BUTTON_STEER_RIGHT = Button.RIGHT_PLUS, Button.RIGHT_MINUS

    # Calibration completed, start the FUN!
    hub.light.on(Color.GREEN)

    # main loop
    while True:
        key.update(remote)

        # gearbox control
        if key.released(Button.LEFT):
            # manual - change to lower gear; auto - switch to driving
            new_pos = gearbox.last_auto_pos if gearbox.auto else gearbox.pos - 1
            gearbox.set_position(new_pos)
        elif key.released(Button.RIGHT):
            # manual - change to higher gear/dumper; auto - switch to dumper
            new_pos = 3 if gearbox.auto else gearbox.pos + 1
            gearbox.set_position(new_pos)
        elif key.released(Button.CENTER):
            # switch gearbox mode to the other one
            gearbox.set_auto(not gearbox.auto)

        # drive control
        drive_direction = direction(key.pressed(BUTTON_DRIVE_FWD), key.pressed(BUTTON_DRIVE_BACK))
        if drive_direction in [-1, 1]:
            # change gear automatically, if gearbox is in AUTO mode
            gearbox.update_auto_gear()
            # for dumper, direction of rotation must be inverted
            invert = 1 if gearbox.dumper() else -1
            drive.dc(invert * drive_direction * 100.0)
            # report active drive
            gearbox.idle(False)
        else:
            drive.stop()
            # report idle drive, if not set to dumper
            gearbox.idle(not gearbox.dumper())

        # steering control
        steer_direction = direction(
            key.pressed(BUTTON_STEER_RIGHT), key.pressed(BUTTON_STEER_LEFT)
        )
        steer.run_target(STEER_SPEED, steer_direction * STEER_ANGLE, wait=False)


This project was submitted by Repkovsky.