VEX V5 PYTHON | 09.1 ACTIONS AND STATES

Guide objective

Split movement into short updates and use explicit states to sequence actions while checking feedback, cancellation and deadlines.

01 | LET THE ROUTINE KEEP CHECKING

A robot starts a movement. While it runs, you want to check a sensor, update telemetry and respond to cancellation. A long blocking action can prevent that work in the same control loop.

A non-blocking action performs a small update and returns. The runner calls it again next cycle until it reports completion or failure.

This lesson restructures autonomous code. It does not make sensors instantaneous or remove the need for physical tuning.

02 | BLOCKING AND NON-BLOCKING ARE DIFFERENT

# Blocking: the next line waits for the motion call to complete.
left_drive.spin_for(FORWARD, 1, TURNS, 15, PERCENT, wait=True)

# Asynchronous motor command: issue once, then inspect progress elsewhere.
left_drive.spin_for(FORWARD, 1, TURNS, 15, PERCENT, wait=False)

These examples operate one motor group, not a complete drivetrain manoeuvre. wait=False returns control so other code can run, but does not create your sequence, timeout or cancellation policy.

For supported finite motor movements, is_done() reports whether the commanded movement has finished. It is not proof of a physically successful robot arrival.

The complete starter below uses continuous velocity requests and explicit encoder/inertial feedback instead of is_done().

03 | DEFINE AN ACTION LIFECYCLE

The runner owns device access and output
PartPurpose
EntryCapture starting feedback and start time once
UpdateRead supplied data, compute progress and return promptly
FinishReport DONE only after the chosen settling policy
FailureReport FAILED with a reason
CleanupStop the output resource at exit or cancellation

Do not recapture the start position on every update. Then the apparent distance travelled would remain close to zero and the action might never finish.

Do not put a long while loop or a wait inside the update method. That would make the action blocking again.

04 | A FINITE STATE MACHINE NAMES THE ACTIVE STEP

A finite state machine stores one explicit current state and changes state only when a condition is met.

Action failure or shared route deadline leads to FAULT
StateWorkTransition
DRIVEApproach 300 mm with heading correctionSettled → TURN
TURNRotate +90° from the heading at entrySettled → PAUSE
PAUSERemain stopped; keep the runner checkingTimer elapsed → DONE
DONEReturn zero outputTerminal
FAULTRecord failure; return zero outputTerminal

An entry action happens once. Ongoing work happens each tick. A transition decides what starts next. Keep those roles separate.

The example has a fixed sequence. More complex routines can branch on results, but each branch should still have an explicit entry condition and budget.

05 | TIMERS REPLACE LONG WAITS

# At entry to PAUSE:
pause_until = now_ms + 500

# During later ticks:
if now_ms >= pause_until:
    state = "DONE"

The main loop keeps checking sensors and mode while the pause is active. A 500 ms pause is represented by a deadline rather than a blocking 500 ms wait.

A short wait at the end of each runner cycle is still useful. It yields time and limits repeated polling. Non-blocking here means actions return promptly, not that the program contains no waits at all.

06 | KEEP ONE OUTPUT OWNER

The action returns desired left and right requests. The state machine selects the active action, and the runner sends those requests to the drivetrain.

This prevents two independent movement functions from issuing conflicting commands to the same motors.

On transitions, the runner receives zero output. The next action begins on the next tick with its start snapshot captured at entry.

Parallel mechanisms can use separate resource owners, covered in 09.2. Parallel drivetrain owners need arbitration, not just extra threads.

07 | COMPLETE COOPERATIVE STATE-MACHINE STARTER

Paste below # Begin project code, preserving generated VEX imports and Brain setup. Manual configuration: left motors 3/4, right 1/2, inertial 20. Avoid duplicate Devices declarations.

MOTION_CONFIGURED begins False. Verify motor direction and encoder signs. Calibrate MM_PER_MOTOR_TURN for actual wheels and external gearing; the sample is illustrative.

The course is a short forward drive, clockwise turn and timed stopped pause. Verify a clear area for the complete motion and footprint; this starter has no obstacle sensor.

# =============================================================================
# TEAM [MY TEAM NAME / NUMBER] | COOPERATIVE AUTONOMOUS STATE MACHINE
# =============================================================================
# Keep generated imports and brain above '# Begin project code'.
# Manual configuration below: do not duplicate declarations in Devices.
import math
left_1 = Motor(Ports.PORT3, GearSetting.RATIO_6_1, False)
left_2 = Motor(Ports.PORT4, GearSetting.RATIO_6_1, False)
right_1 = Motor(Ports.PORT1, GearSetting.RATIO_6_1, True)
right_2 = Motor(Ports.PORT2, GearSetting.RATIO_6_1, True)
left_drive = MotorGroup(left_1, left_2)
right_drive = MotorGroup(right_1, right_2)
inertial = Inertial(Ports.PORT20)
controller = Controller(PRIMARY)

MOTION_CONFIGURED = False
MM_PER_MOTOR_TURN = 219.44  # Example: calibrate wheel size/external gearing
DRIVE_MM = 300.0
TURN_DEG = 90.0            # Relative clockwise turn using continuous rotation
DRIVE_KP = 0.05
TURN_KP = 0.7
MAX_DRIVE = 20.0
MAX_TURN = 10.0
MAX_WHEEL_OUTPUT = 25.0
DISTANCE_TOL_MM = 10.0
HEADING_TOL_DEG = 2.0
SIDE_RATE_TOL_MM_S = 30.0
TURN_RATE_TOL_DEG_S = 8.0
SETTLE_MS = 200
DRIVE_TIMEOUT_MS = 8000
TURN_TIMEOUT_MS = 6000
PAUSE_MS = 500
ROUTE_TIMEOUT_MS = 14000
POLL_MS = 20
MAX_UPDATE_GAP_MS = 100
ready = False

def clamp(v, low, high):
    return max(low, min(high, v))

def angle_error(target, current):
    return (target-current+180)%360-180

def stop_drive():
    left_drive.stop(BRAKE)
    right_drive.stop(BRAKE)

def side_output(group, value):
    if value == 0:
        group.stop(BRAKE)
    else:
        group.spin(FORWARD if value > 0 else REVERSE, abs(value), PERCENT)

def read_snapshot():
    if not inertial.installed() or inertial.is_calibrating():
        raise ValueError("Inertial unavailable")
    turns = (left_1.position(TURNS), left_2.position(TURNS),
             right_1.position(TURNS), right_2.position(TURNS))
    rotation = inertial.rotation(DEGREES)
    if not all(math.isfinite(v) for v in turns+(rotation,)):
        raise ValueError("Invalid encoder/inertial data")
    return {"left": (turns[0]+turns[1])*MM_PER_MOTOR_TURN/2,
            "right": (turns[2]+turns[3])*MM_PER_MOTOR_TURN/2,
            "rotation": rotation}

# =============================================================================
# ACTION: NO LOOPS, WAITS OR DEVICE ACCESS INSIDE update()
# =============================================================================
class MotionAction:
    def __init__(self, mode, now, snapshot):
        if mode not in ("DRIVE", "TURN"):
            raise ValueError("Invalid action mode")
        self.mode = mode
        self.started = now
        self.start = dict(snapshot)  # Capture once; never reset on each update.
        self.settled_since = None
        self.finished = None

    def update(self, now, snapshot, slow):
        if self.finished is not None:
            return self.finished, 0.0, 0.0, ""
        limit = DRIVE_TIMEOUT_MS if self.mode == "DRIVE" else TURN_TIMEOUT_MS
        if now-self.started >= limit:
            self.finished = "FAILED"
            return "FAILED", 0.0, 0.0, self.mode+"_TIMEOUT"
        if self.mode == "DRIVE":
            left_distance = snapshot["left"]-self.start["left"]
            right_distance = snapshot["right"]-self.start["right"]
            distance_error = DRIVE_MM-(left_distance+right_distance)/2
            heading_error = angle_error(self.start["rotation"], snapshot["rotation"])
            if distance_error < -DISTANCE_TOL_MM:
                self.finished = "FAILED"
                return "FAILED", 0.0, 0.0, "DRIVE_OVERSHOOT"
            near = (abs(DRIVE_MM-left_distance) <= DISTANCE_TOL_MM
                    and abs(DRIVE_MM-right_distance) <= DISTANCE_TOL_MM
                    and abs(heading_error) <= HEADING_TOL_DEG)
            base = (clamp(DRIVE_KP*distance_error, 0, MAX_DRIVE)
                    if abs(heading_error) <= 10 else 0.0)
            turn = clamp(TURN_KP*heading_error, -MAX_TURN, MAX_TURN)
        else:
            # Relative target is fixed at entry; continuous rotation preserves +90.
            heading_error = self.start["rotation"]+TURN_DEG-snapshot["rotation"]
            near = abs(heading_error) <= HEADING_TOL_DEG
            base = 0.0
            turn = clamp(TURN_KP*heading_error, -MAX_TURN, MAX_TURN)
        if near:
            if slow:
                if self.settled_since is None:
                    self.settled_since = now
                if now-self.settled_since >= SETTLE_MS:
                    self.finished = "DONE"
                    return "DONE", 0.0, 0.0, ""
            else:
                self.settled_since = None
            return "RUNNING", 0.0, 0.0, ""  # Brake while observing settling.
        self.settled_since = None
        left, right = base+turn, base-turn
        scale = max(1.0, abs(left)/MAX_WHEEL_OUTPUT, abs(right)/MAX_WHEEL_OUTPUT)
        return "RUNNING", left/scale, right/scale, ""

# =============================================================================
# FINITE STATE MACHINE: DRIVE -> TURN -> PAUSE -> DONE; FAILURE -> FAULT
# =============================================================================
class AutoMachine:
    def __init__(self, now, snapshot):
        self.state = "DRIVE"
        self.reason = ""
        self.deadline = now+ROUTE_TIMEOUT_MS
        self.action = MotionAction("DRIVE", now, snapshot)
        self.pause_until = None

    def fail(self, reason):
        self.state, self.reason = "FAULT", reason
        return 0.0, 0.0

    def tick(self, now, snapshot, slow):
        if self.state in ("DONE", "FAULT"):
            return 0.0, 0.0
        if now >= self.deadline:
            return self.fail("ROUTE_DEADLINE")
        if self.state == "PAUSE":
            if now >= self.pause_until:
                self.state = "DONE"
            return 0.0, 0.0
        status, left, right, reason = self.action.update(now, snapshot, slow)
        if status == "FAILED":
            return self.fail(reason)
        if status == "DONE":
            if self.state == "DRIVE":
                self.state = "TURN"
                self.action = MotionAction("TURN", now, snapshot)  # Enter once.
            else:
                self.state = "PAUSE"
                self.pause_until = now+PAUSE_MS
                self.action = None
            return 0.0, 0.0  # Stop at the transition; next action starts next tick.
        return left, right

# =============================================================================
# COOPERATIVE RUNNER: ONE SNAPSHOT, ONE TICK, ONE OUTPUT OWNER
# =============================================================================
def run_machine():
    stop_drive()
    machine = None
    try:
        settings = (MM_PER_MOTOR_TURN, DRIVE_MM, TURN_DEG, DRIVE_KP, TURN_KP,
                    MAX_DRIVE, MAX_TURN, MAX_WHEEL_OUTPUT, DISTANCE_TOL_MM,
                    HEADING_TOL_DEG, SIDE_RATE_TOL_MM_S, TURN_RATE_TOL_DEG_S,
                    SETTLE_MS, DRIVE_TIMEOUT_MS, TURN_TIMEOUT_MS, PAUSE_MS,
                    ROUTE_TIMEOUT_MS, POLL_MS, MAX_UPDATE_GAP_MS)
        if (not all(math.isfinite(v) for v in settings)
                or min(MM_PER_MOTOR_TURN, DRIVE_MM, DRIVE_KP, TURN_KP,
                       MAX_DRIVE, MAX_TURN, MAX_WHEEL_OUTPUT, DISTANCE_TOL_MM,
                       HEADING_TOL_DEG, SETTLE_MS, DRIVE_TIMEOUT_MS,
                       TURN_TIMEOUT_MS, ROUTE_TIMEOUT_MS) <= 0
                or PAUSE_MS < 0 or SIDE_RATE_TOL_MM_S < 0 or TURN_RATE_TOL_DEG_S < 0
                or not 0 < POLL_MS < MAX_UPDATE_GAP_MS
                or max(MAX_DRIVE, MAX_TURN) > MAX_WHEEL_OUTPUT
                or MAX_WHEEL_OUTPUT > 25):
            return "INVALID_SETTINGS"
        previous = None
        previous_ms = brain.timer.time(MSEC)
        while True:
            if (not ready or not MOTION_CONFIGURED or not competition.is_enabled()
                    or not competition.is_autonomous() or controller.buttonB.pressing()):
                return "CANCELLED_OR_NOT_READY"
            snapshot = read_snapshot()
            now = brain.timer.time(MSEC)
            if now-previous_ms > MAX_UPDATE_GAP_MS:
                return "UPDATE_GAP"
            # Mode can change during an API call: check again before tick/output.
            if (not competition.is_enabled() or not competition.is_autonomous()
                    or controller.buttonB.pressing()):
                return "CANCELLED_OR_NOT_READY"
            dt = (now-previous_ms)/1000.0
            slow = (previous is not None and dt > 0 and
                    max(abs(snapshot["left"]-previous["left"]),
                        abs(snapshot["right"]-previous["right"]))/dt <= SIDE_RATE_TOL_MM_S
                    and abs(snapshot["rotation"]-previous["rotation"])/dt <= TURN_RATE_TOL_DEG_S)
            previous, previous_ms = snapshot, now
            if machine is None:
                machine = AutoMachine(now, snapshot)
                print("STATE", machine.state)
            old_state = machine.state
            left, right = machine.tick(now, snapshot, slow)
            if machine.state != old_state:
                print("STATE", old_state, "->", machine.state, "REASON", machine.reason)
            side_output(left_drive, left)
            side_output(right_drive, right)
            if machine.state in ("DONE", "FAULT"):
                return machine.state+": "+machine.reason
            wait(POLL_MS, MSEC)  # Short cooperative yield; no long action wait.
    except Exception:
        return "SENSOR_OR_ACTION_FAULT"
    finally:
        stop_drive()

# =============================================================================
# COMPETITION CALLBACKS AND DISABLED SETUP
# =============================================================================
def autonomous():
    stop_drive()
    if ready and MOTION_CONFIGURED:
        print("machine result", run_machine())

def driver_control():
    stop_drive()  # Teaching project: no driver movement.

competition = Competition(driver_control, autonomous)
stop_drive()
if MOTION_CONFIGURED and not competition.is_enabled() and inertial.installed():
    inertial.calibrate()  # Stationary disabled setup; may block.
    ready = not inertial.is_calibrating() and not competition.is_enabled()
while True:
    wait(100, MSEC)

MotionAction.update() and AutoMachine.tick() contain no waits or device reads. The runner obtains one feedback snapshot and calls one tick per cycle.

Encoder-derived side speeds support the settling test. They are wheel-motion estimates, not proof that the robot did not slip or move physically.

08 | USE ACTION AND ROUTE DEADLINES

The drive action has eight seconds, the turn has six seconds and the entire sequence has fourteen seconds. The pause consumes route time too.

The machine checks the shared route deadline before accepting another transition. An action also checks its own time limit before reporting completion.

Timeout means failure. It does not advance the machine to the next normal step. The starter also stops rather than automatically reversing if the drive overshoots beyond its tolerance.

09 | CHECK CANCELLATION AND FEEDBACK EVERY CYCLE

The runner checks enable state, autonomous mode and B. It checks again after reading devices because the mode could change during an API call.

An excessive update gap or invalid feedback ends the routine. Normal returns and caught exceptions brake through finally.

Short ticks improve responsiveness, but actual latency also includes sensor calls and motor commands. A timing check cannot interrupt an API call that remains blocked.

Test mode transitions. Forced task termination is different from an ordinary function exit and can bypass expected cleanup.

10 | TRACE THE MACHINE BEFORE MOVING

With mocked snapshots, provide a drive endpoint, low-motion samples and then a turn endpoint. Confirm that the transition order is DRIVE, TURN, PAUSE, DONE.

Call tick repeatedly while a state is unfinished. Confirm its entry data stays fixed. Then inject a timeout and verify the machine remains terminal in FAULT.

Useful evidence before physical tests
TestExpected result
Drive still progressingDRIVE; capped output
Drive inside window but movingBrake; do not transition yet
Drive settled for 200 msTURN entry captured once
Turn settledPAUSE; zero output
Pause elapsedDONE; zero output
Action failedFAULT; no later normal action
B / disabled / wrong modeRunner exits and brakes

The console logs transitions, rather than printing every tick. This makes it easier to see a repeated entry or unexpected branch.

11 | COMMON MISTAKES

  • Starting a finite motor command every tick: issue it once on entry.
  • Calling a blocking lesson helper inside update: it still blocks the runner.
  • Using is_done as physical arrival evidence: define the required feedback checks.
  • Advancing after any return: DONE and FAILED need different transitions.
  • Resetting the action timer each tick: preserve its entry time.
  • Letting two tasks write the drivetrain: maintain one output owner.
  • Spinning without a short yield: polling faster does not guarantee fresher feedback.

CHALLENGES | BUILD, TRACE AND CANCEL

  1. Explain: distinguish asynchronous motor commands from an action state machine.
  2. Trace: record the state and action entry time over ten unfinished updates.
  3. Mock: show that a drive timeout cannot start TURN.
  4. Verify: prove DONE and FAULT always return zero output on later ticks.
  5. Change: add a second stopped pause using a timer transition.
  6. Test: cancel during each active state.
  7. Measure: log real cycle duration and cancellation response.
  8. Design: describe how an intake action could run alongside DRIVE without sharing the drivetrain resource.
Success evidence

A state-transition trace, fixed entry snapshots, failure and cancellation tests, and repeated physical trials with measured endpoint and turn errors.

OFFICIAL REFERENCES

The action lifecycle and state-machine starter are independently written teaching examples requiring configuration, physical testing and tuning.