4. My script moves a simulated robot

Your own code, your own simulator

ImportantDownload academy.py into ~/dotbot-academy

Every script from this step on imports academy, the workshop’s small helper. Save it into your ~/dotbot-academy folder, the one with your .venv, before anything else. Already have an academy.py there? Replace it with this one.

Download academy.py

Or open the block below, copy it with the button at its top right, and save it as academy.py.

You do not need to read it to use it. Its _put line is the same REST call as the docs’ example.

academy.py
"""A small helper for the DotBot Academy workshop, over the controller's REST API."""

import math
import time

import requests

DOTBOT = 0  # the `{application}` segment of a robot's routes
ARRIVED = 2  # the robot's waypoints_status once it reaches a goto() target
BASE = "http://localhost:8000/controller"


def connect(base_url="http://localhost:8000"):
    """Point every helper at a controller, e.g. http://192.168.1.10:8000."""
    global BASE
    BASE = base_url.rstrip("/") + "/controller"
    return all_bots()


class Bot:
    def __init__(self, address):
        self.address = address
        self.target = None
        self.threshold = None

    def __repr__(self):
        return f"Bot({self.address})"

    def _put(self, route, body):
        url = f"{BASE}/dotbots/{self.address}/{DOTBOT}/{route}"
        requests.put(url, json=body, timeout=2).raise_for_status()

    def led(self, red, green, blue):
        """Set the RGB LED, each channel 0-255."""
        self._put("rgb_led", {"red": red, "green": green, "blue": blue})

    def goto(self, x, y, threshold=60):
        """Drive on board to (x, y) in mm; stops within `threshold` mm.

        Sends the target and returns at once; use arrived() to wait.
        """
        self._put("waypoints", {"threshold": threshold, "waypoints": [{"x": x, "y": y}]})
        self.target = (x, y)
        self.threshold = threshold

    def arrived(self):
        """True once the robot has reached its last goto() target."""
        bot = requests.get(f"{BASE}/dotbots/{self.address}", timeout=2).json()
        pos = bot.get("lh2_position")
        if self.target is None or pos is None or bot.get("waypoints_status") != ARRIVED:
            return False
        # The status can still be the previous goto()'s, so check where it is too
        return distance((pos["x"], pos["y"]), self.target) <= self.threshold + 50

    def position(self):
        """The LH2 position as (x, y) in mm, or None if not localized."""
        bot = requests.get(f"{BASE}/dotbots/{self.address}", timeout=2).json()
        pos = bot.get("lh2_position")
        return (pos["x"], pos["y"]) if pos else None

    def drive(self, speed=100, seconds=1):
        """Drive straight at `speed` mm/s (negative backs up) for `seconds`, then stop."""
        self.wheels(speed, speed, seconds)

    def spin(self, speed=100, seconds=1):
        """Turn on the spot, clockwise on the map (negative: anticlockwise), then stop."""
        self.wheels(speed, -speed, seconds)

    def wheels(self, left, right, seconds):
        """Each wheel's speed in mm/s, -700..700, for `seconds`, then stop."""
        self._repeat("wheel_velocity", {"left_mm_s": left, "right_mm_s": right}, seconds)

    def drive_raw(self, left, right, seconds):
        """Motor duty per wheel, -127..127, no speed control; below about 51 a
        wheel at rest does not turn."""
        body = {"left_x": 0, "left_y": left, "right_x": 0, "right_y": right}
        self._repeat("move_raw", body, seconds)

    def stop(self):
        self._put("wheel_velocity", {"left_mm_s": 0, "right_mm_s": 0})

    def _repeat(self, route, body, seconds):
        # The robot stops by itself ~0.5 s after the last driving command
        end = time.time() + seconds
        while time.time() < end:
            self._put(route, body)
            time.sleep(0.1)
        self.stop()


def distance(a, b):
    """Distance in mm between two (x, y) points."""
    return math.dist(a, b)


def all_bots():
    """Every robot the controller knows."""
    bots = requests.get(f"{BASE}/dotbots", timeout=2).json()
    return [Bot(b["address"]) for b in bots]


def bot(label):
    """Your robot, from the 6 hex digits on its label (or a full address)."""
    label = label.upper()
    found = [b for b in all_bots() if b.address.upper().endswith(label)]
    if len(found) != 1:
        raise LookupError(f"{len(found)} robots match {label!r}, expected 1")
    return found[0]


def swarm(labels):
    """Your robots, from their labels."""
    return [bot(label) for label in labels]

The problem. The console’s pad drives one robot with one hand; to drive a swarm, your code has to do the driving.

The PyDotBot docs show the idea in Drive it from your own code: a script that sends commands to the controller over its REST API. The workshop wraps that in the small helper you just downloaded, academy.py. This step builds one script, a command at a time: led() sets the robot’s color, drive() goes straight, spin() turns on the spot.

Keep the step 3 simulator running, and work in your second terminal, in ~/dotbot-academy with the environment active.

Hello: light it

Save hello.py (below) into your ~/dotbot-academy folder, next to academy.py. The copy button is at the top right of the block.

hello.py
import time

import academy

academy.connect("http://localhost:8000")  # your simulator
robot = academy.bot("000000")  # its first robot, by label
robot.led(255, 0, 0)  # red: red, green, blue, each 0 to 255
time.sleep(1)
robot.led(0, 255, 0)  # green
time.sleep(1)
robot.led(0, 0, 255)  # blue

academy.bot("000000") picks a robot by its label, the last six characters of its address: 000000 is the first robot of your simulator. robot.led(red, green, blue) sets its color, each channel from 0 to 255. Run it:

python hello.py

With the environment active, python is the right command on every system.

You should see: on the map, robot 000000 turns red, then green, then blue, one second apart.

Core drive it

robot.drive(speed, seconds) drives straight at speed mm/s for seconds, then stops by itself. A negative speed backs up.

drive() and spin() wait until the move is done.

Your turn. Add one line at the end of hello.py so the robot drives forward at 50 mm/s for 2 seconds, then run it again.

You should see: the robot changes color as before, then drives about 100 mm up the map and stops.

Then change the speed and the time: try speed=100, seconds=1. Same distance? The distance is about the speed times the time.

hello.py
import time

import academy

academy.connect("http://localhost:8000")  # your simulator
robot = academy.bot("000000")  # its first robot, by label
robot.led(255, 0, 0)  # red: red, green, blue, each 0 to 255
time.sleep(1)
robot.led(0, 255, 0)  # green
time.sleep(1)
robot.led(0, 0, 255)  # blue
robot.drive(speed=50, seconds=2)  # 50 mm/s for 2 s: about 100 mm

Your turn. How do you make it come back to where it started?

You should see: the robot drives about 100 mm up the map, then backs up the same 100 mm and stops where it started.

hello.py
import time

import academy

academy.connect("http://localhost:8000")  # your simulator
robot = academy.bot("000000")  # its first robot, by label
robot.led(255, 0, 0)  # red: red, green, blue, each 0 to 255
time.sleep(1)
robot.led(0, 255, 0)  # green
time.sleep(1)
robot.led(0, 0, 255)  # blue
robot.drive(speed=50, seconds=2)  # 50 mm/s for 2 s: about 100 mm
robot.drive(speed=-50, seconds=2)  # back the same 100 mm, to the start

Core turn it

robot.spin(speed, seconds) turns the robot on the spot, its wheels at speed mm/s in opposite directions, for seconds, then stops. A positive speed turns clockwise on the map, a negative one anticlockwise. It turns for a time, not by an angle.

Your turn. After the drive, add a spin to the left (a negative speed) at 50 mm/s for 5 seconds, then run it again.

You should see: the robot drives about 100 mm up the map and back, then turns left on the spot, about one full turn, and stops facing up the map again.

hello.py
import time

import academy

academy.connect("http://localhost:8000")  # your simulator
robot = academy.bot("000000")  # its first robot, by label
robot.led(255, 0, 0)  # red: red, green, blue, each 0 to 255
time.sleep(1)
robot.led(0, 255, 0)  # green
time.sleep(1)
robot.led(0, 0, 255)  # blue
robot.drive(speed=50, seconds=2)  # 50 mm/s for 2 s: about 100 mm
robot.drive(speed=-50, seconds=2)  # back the same 100 mm, to the start
robot.spin(speed=-50, seconds=5)  # left, on the spot: about a full turn

Challenge: a square

NoteA challenge, if there is time

The instructor may skip it. Step 6 runs these same scripts on a real robot, the challenge too if there is time, so you need nothing from it to go on.

Save square.py (below) into the same folder, fill in its three TODOs, then run python square.py.

You should see: robot 000000 drives a square about 200 mm a side, turning left at each corner, with a new color on each side, and ends close to where it started, facing the same way.

square.py
"""Step 4, challenge: a square.

Building on hello.py: drive a square, one color per side, turning left
(anticlockwise) at each corner.
You should see: on the map, the robot traces a square about 200 mm a side
and ends close to where it started, facing the same way.
"""

import academy

BASE_URL = "http://localhost:8000"  # your own simulator
LABEL = "000000"  # the simulator's first robot; step 6 uses the label on your real robot
QUARTER_TURN = 1.2  # seconds for about 90 degrees: approximate, tune it

academy.connect(BASE_URL)
robot = academy.bot(LABEL)

COLORS = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255)]

# 1. Drive one side: forward at 100 mm/s for 2 s, which is about 200 mm.
# TODO

# 2. Turn a quarter to the left: spin at -50 mm/s (negative is anticlockwise)
#    for QUARTER_TURN seconds.
# TODO

# 3. Make it a square: put 1 and 2 in a loop over COLORS, setting the LED to
#    each color before its side.
# TODO

robot.led(0, 0, 0)  # off: done

QUARTER_TURN is approximate: spin() turns for a time, not by an angle. If the corners are too wide or too tight, change it a little and run it again.

square.py
"""Step 4, challenge: a square.

Building on hello.py: drive a square, one color per side, turning left
(anticlockwise) at each corner.
You should see: on the map, the robot traces a square about 200 mm a side
and ends close to where it started, facing the same way.
"""

import academy

BASE_URL = "http://localhost:8000"  # your own simulator
LABEL = "000000"  # the simulator's first robot; step 6 uses the label on your real robot
QUARTER_TURN = 1.2  # seconds for about 90 degrees: approximate, tune it

academy.connect(BASE_URL)
robot = academy.bot(LABEL)

COLORS = [(255, 0, 0), (0, 255, 0), (0, 0, 255), (255, 255, 255)]

# 3. One side per color
for red, green, blue in COLORS:
    robot.led(red, green, blue)
    # 1. Drive one side: forward at 100 mm/s for 2 s, which is about 200 mm.
    robot.drive(speed=100, seconds=2)
    # 2. Turn a quarter to the left: a negative speed spins anticlockwise.
    robot.spin(speed=-50, seconds=QUARTER_TURN)

robot.led(0, 0, 0)  # off: done