|
| 1 | +# Controlling LEGO Volvo Articulated Hauler (42114) with Bluetooth Remote (88010) |
| 2 | +# Version 1.2, works with PyBricks 3.1 |
| 3 | + |
| 4 | +from pybricks.hubs import TechnicHub |
| 5 | +from pybricks.pupdevices import Motor, Remote |
| 6 | +from pybricks.parameters import Port, Button, Color |
| 7 | +from pybricks.tools import wait |
| 8 | + |
| 9 | +# control buttons swapping |
| 10 | +LEFT_STEER_RIGHT_DRIVE = True |
| 11 | + |
| 12 | +# set gearbox to AUTO mode at startup? |
| 13 | +INIT_GEARBOX_AUTO = True |
| 14 | + |
| 15 | +# steering settings |
| 16 | +STEER_ANGLE = 65 |
| 17 | +STEER_SPEED = 1000 |
| 18 | +STEER_HARDNESS = 4 |
| 19 | + |
| 20 | +class Gearbox: |
| 21 | + # if speed is stable above, automatic gearbox will increase gear |
| 22 | + HI_SPEED = 1400 |
| 23 | + # if speed is stable below, automatic gearbox will decrease gear |
| 24 | + LO_SPEED = 300 |
| 25 | + # time [ms] after gearbox switches to 1st gear when drive remains idle |
| 26 | + GEAR_RESET_TIMEOUT = 2000 |
| 27 | + # time [ms] of speed stability measurement before automatic gear change |
| 28 | + STABLE_SPEED_TIME = 800 |
| 29 | + # time [ms] for normal gear switch |
| 30 | + GEAR_SWITCH_TIMEOUT = 1500 |
| 31 | + # speed measurement smoothing factor |
| 32 | + SMOOTHING = 0.05 |
| 33 | + # colors of gearbox state indicator LED for automatic (True) and manual (False) |
| 34 | + # [gear 1, gear 2, gear 3, dumper] |
| 35 | + POS_COLOR = {True: [Color.CYAN, Color.BLUE, Color.MAGENTA, Color.GREEN], |
| 36 | + False: [Color.ORANGE, Color(h=15, s=100, v=100), |
| 37 | + Color(h=5, s=100, v=100), Color.GREEN]} |
| 38 | + def __init__(self, remote: Remote, hub: TechnicHub, drive: Motor): |
| 39 | + # assign external objects to properties of the class |
| 40 | + self.remote = remote |
| 41 | + self.hub = hub |
| 42 | + self.drive = drive |
| 43 | + # initialize control variables |
| 44 | + self.speed_timer = 0 |
| 45 | + self.idle_timer = 0 |
| 46 | + self.speed = 0 |
| 47 | + # initialize L motor |
| 48 | + self.gearbox = Motor(Port.B) |
| 49 | + self.calibrate() |
| 50 | + # set defaults |
| 51 | + self.last_auto_pos = 0 |
| 52 | + self.set_auto(INIT_GEARBOX_AUTO) |
| 53 | + |
| 54 | + def calibrate(self): |
| 55 | + # calibrate gearbox motor by finding its physical rotation limit; |
| 56 | + # first, move left at full power to handle possible jam in gearbox |
| 57 | + self.gearbox.run_until_stalled(360) |
| 58 | + # second, correct the position |
| 59 | + self.gearbox.run_angle(360, -90) |
| 60 | + # finally move left with small power to avoid twisting 12-axle and measurement error |
| 61 | + stalled_angle = self.gearbox.run_until_stalled(360, duty_limit=10) |
| 62 | + # round to multiple of 90 degrees and subtract angle of physical block (90deg) |
| 63 | + base_angle = 90*round(stalled_angle/90)-90 |
| 64 | + # adjust settings of possible motor positions |
| 65 | + self.pos_angle = [p+base_angle for p in [90, 0, -90, -180]] |
| 66 | + self.pos = 0 |
| 67 | + |
| 68 | + def set_position(self, pos): |
| 69 | + # limit positions to range 0,1,2,3 |
| 70 | + pos = min(3, max(pos, 0)) |
| 71 | + # apply new position, if it is different from the current one |
| 72 | + if self.pos != pos: |
| 73 | + # set remote control light according to mode and position |
| 74 | + self.remote.light.on(self.POS_COLOR[self.auto][pos]) |
| 75 | + # stop drive to allow smooth gear change |
| 76 | + self.drive.stop() |
| 77 | + # rotate gearbox to angle that corresponds position |
| 78 | + self.gearbox.run_target(720, target_angle=self.pos_angle[pos], wait=False) |
| 79 | + # control time of gear change, to detect possible position mismatch |
| 80 | + change_time = 0 |
| 81 | + while not self.gearbox.control.done() and change_time < self.GEAR_SWITCH_TIMEOUT: |
| 82 | + # measure the switching time |
| 83 | + change_time += 1 |
| 84 | + wait(1) |
| 85 | + if change_time == self.GEAR_SWITCH_TIMEOUT: |
| 86 | + # timeout occured - something went wrong, true gearbox position |
| 87 | + # is different than expected - set hub LED to red |
| 88 | + self.hub.light.on(Color.RED) |
| 89 | + # stop switching and recalibrate |
| 90 | + self.gearbox.stop() |
| 91 | + self.calibrate() |
| 92 | + # 1st gear is set |
| 93 | + pos = 0 |
| 94 | + self.remote.light.on(self.POS_COLOR[self.auto][pos]) |
| 95 | + self.hub.light.on(Color.GREEN) |
| 96 | + # remember last automatic gear |
| 97 | + self.last_auto_pos = self.pos if pos == 3 else pos |
| 98 | + # update gear state variable |
| 99 | + self.pos = pos |
| 100 | + |
| 101 | + def dumper(self): |
| 102 | + # return whether gearbox is set to drive dumper |
| 103 | + return self.pos == 3 |
| 104 | + |
| 105 | + def set_auto(self, auto): |
| 106 | + # set AUTO/MANUAL mode and update control light |
| 107 | + self.auto = auto |
| 108 | + self.remote.light.on(self.POS_COLOR[self.auto][self.pos]) |
| 109 | + |
| 110 | + def update_auto_gear(self): |
| 111 | + # in AUTO mode changes gear if speed is stable below/above LO_SPEED/HI_SPEED threshold |
| 112 | + if self.auto and not self.dumper(): |
| 113 | + speed = self.drive.speed() |
| 114 | + # basic low-pass filtering (exponential smoothing) |
| 115 | + self.speed += self.SMOOTHING*(abs(speed)-self.speed) |
| 116 | + wait(10) |
| 117 | + if self.LO_SPEED < self.speed < self.HI_SPEED: |
| 118 | + # speed in medium range, reset timer |
| 119 | + self.speed_timer = 0 |
| 120 | + else: |
| 121 | + # speed out of medium range, increase time of measurement |
| 122 | + self.speed_timer += 10 |
| 123 | + if self.speed_timer > self.STABLE_SPEED_TIME: |
| 124 | + # speed is stable - reset timer |
| 125 | + self.speed_timer = 0 |
| 126 | + # depending on speed and current position, |
| 127 | + # return lower, higher or None gear (no change) |
| 128 | + if self.pos > 0 and self.speed < self.LO_SPEED: |
| 129 | + self.set_position(self.pos - 1) |
| 130 | + elif self.pos < 2 and self.speed > self.HI_SPEED: |
| 131 | + self.set_position(self.pos + 1) |
| 132 | + |
| 133 | + def idle(self, persists): |
| 134 | + if persists: |
| 135 | + # increase idle time |
| 136 | + wait(1) |
| 137 | + self.idle_timer += 1 |
| 138 | + if self.auto and self.idle_timer >= self.GEAR_RESET_TIMEOUT: |
| 139 | + # reset gearbox to lowest gear |
| 140 | + self.idle_timer = 0 |
| 141 | + gearbox.set_position(0) |
| 142 | + else: |
| 143 | + self.idle_timer = 0 |
| 144 | + |
| 145 | +class Key: |
| 146 | + def __init__(self): |
| 147 | + # variables to store current and previous state of buttons |
| 148 | + self.now_pressed = [] |
| 149 | + self.prev_pressed = [] |
| 150 | + |
| 151 | + def update(self, remote): |
| 152 | + # copy list of keys pressed during last update |
| 153 | + self.prev_pressed = list(self.now_pressed) |
| 154 | + # update list of pressed keys |
| 155 | + self.now_pressed = remote.buttons.pressed() |
| 156 | + |
| 157 | + def pressed(self, key): |
| 158 | + # return whether key is now pressed |
| 159 | + return key in self.now_pressed |
| 160 | + |
| 161 | + def released(self, key): |
| 162 | + # return keys which were released after last update |
| 163 | + return key in self.prev_pressed and key not in self.now_pressed |
| 164 | + |
| 165 | +def direction(positive, negative): |
| 166 | + # return resultant value of two boolean directions |
| 167 | + return int(bool(positive)) - int(bool(negative)) |
| 168 | + |
| 169 | +if __name__ == '__main__': |
| 170 | + CONNECT_FLASHING_TIME = [75]*5 + [1000] |
| 171 | + hub = TechnicHub() |
| 172 | + # Flashing led while waiting connection as remote do |
| 173 | + hub.light.blink(Color.WHITE, CONNECT_FLASHING_TIME) |
| 174 | + |
| 175 | + # Connect to the remote. |
| 176 | + remote = Remote() |
| 177 | + print('Remote connected.') |
| 178 | + |
| 179 | + # Wait for calibration |
| 180 | + hub.light.on(Color.YELLOW) |
| 181 | + |
| 182 | + # initialize driving motor |
| 183 | + drive = Motor(Port.A) |
| 184 | + |
| 185 | + # initialize steering motor |
| 186 | + steer = Motor(Port.D) |
| 187 | + kp, ki, _, _, _ = steer.control.pid() |
| 188 | + steer.control.limits(speed=STEER_SPEED) |
| 189 | + steer.control.pid(kp=kp*STEER_HARDNESS, ki=ki*STEER_HARDNESS) |
| 190 | + |
| 191 | + # initialize gearbox |
| 192 | + gearbox = Gearbox(remote, hub, drive) |
| 193 | + |
| 194 | + # initialize remote keys |
| 195 | + key = Key() |
| 196 | + if LEFT_STEER_RIGHT_DRIVE: |
| 197 | + BUTTON_DRIVE_FWD, BUTTON_DRIVE_BACK = Button.RIGHT_PLUS, Button.RIGHT_MINUS |
| 198 | + BUTTON_STEER_LEFT, BUTTON_STEER_RIGHT = Button.LEFT_PLUS, Button.LEFT_MINUS |
| 199 | + else: |
| 200 | + BUTTON_DRIVE_FWD, BUTTON_DRIVE_BACK = Button.LEFT_PLUS, Button.LEFT_MINUS |
| 201 | + BUTTON_STEER_LEFT, BUTTON_STEER_RIGHT = Button.RIGHT_PLUS, Button.RIGHT_MINUS |
| 202 | + |
| 203 | + # Calibration completed, start the FUN! |
| 204 | + hub.light.on(Color.GREEN) |
| 205 | + |
| 206 | + # main loop |
| 207 | + while True: |
| 208 | + key.update(remote) |
| 209 | + |
| 210 | + # gearbox control |
| 211 | + if key.released(Button.LEFT): |
| 212 | + # manual - change to lower gear; auto - switch to driving |
| 213 | + new_pos = gearbox.last_auto_pos if gearbox.auto else gearbox.pos-1 |
| 214 | + gearbox.set_position(new_pos) |
| 215 | + elif key.released(Button.RIGHT): |
| 216 | + # manual - change to higher gear/dumper; auto - switch to dumper |
| 217 | + new_pos = 3 if gearbox.auto else gearbox.pos+1 |
| 218 | + gearbox.set_position(new_pos) |
| 219 | + elif key.released(Button.CENTER): |
| 220 | + # switch gearbox mode to the other one |
| 221 | + gearbox.set_auto(not gearbox.auto) |
| 222 | + |
| 223 | + # drive control |
| 224 | + drive_direction = direction(key.pressed(BUTTON_DRIVE_FWD), |
| 225 | + key.pressed(BUTTON_DRIVE_BACK)) |
| 226 | + if drive_direction in [-1,1]: |
| 227 | + # change gear automatically, if gearbox is in AUTO mode |
| 228 | + gearbox.update_auto_gear() |
| 229 | + # for dumper, direction of rotation must be inverted |
| 230 | + invert = 1 if gearbox.dumper() else -1 |
| 231 | + drive.dc(invert*drive_direction*100.0) |
| 232 | + # report active drive |
| 233 | + gearbox.idle(False) |
| 234 | + else: |
| 235 | + drive.stop() |
| 236 | + # report idle drive, if not set to dumper |
| 237 | + gearbox.idle(not gearbox.dumper()) |
| 238 | + |
| 239 | + # steering control |
| 240 | + steer_direction = direction(key.pressed(BUTTON_STEER_RIGHT), |
| 241 | + key.pressed(BUTTON_STEER_LEFT)) |
| 242 | + steer.run_target(STEER_SPEED, steer_direction*STEER_ANGLE, wait=False) |
0 commit comments