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
| Part | Purpose |
|---|---|
| Entry | Capture starting feedback and start time once |
| Update | Read supplied data, compute progress and return promptly |
| Finish | Report DONE only after the chosen settling policy |
| Failure | Report FAILED with a reason |
| Cleanup | Stop 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.
| State | Work | Transition |
|---|---|---|
| DRIVE | Approach 300 mm with heading correction | Settled → TURN |
| TURN | Rotate +90° from the heading at entry | Settled → PAUSE |
| PAUSE | Remain stopped; keep the runner checking | Timer elapsed → DONE |
| DONE | Return zero output | Terminal |
| FAULT | Record failure; return zero output | Terminal |
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.
| Test | Expected result |
|---|---|
| Drive still progressing | DRIVE; capped output |
| Drive inside window but moving | Brake; do not transition yet |
| Drive settled for 200 ms | TURN entry captured once |
| Turn settled | PAUSE; zero output |
| Pause elapsed | DONE; zero output |
| Action failed | FAULT; no later normal action |
| B / disabled / wrong mode | Runner 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
- Explain: distinguish asynchronous motor commands from an action state machine.
- Trace: record the state and action entry time over ten unfinished updates.
- Mock: show that a drive timeout cannot start TURN.
- Verify: prove DONE and FAULT always return zero output on later ticks.
- Change: add a second stopped pause using a timer transition.
- Test: cancel during each active state.
- Measure: log real cycle duration and cancellation response.
- 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.