Stop treating each subsystem as its own script, and give the whole machine one coherent control loop.
Up to now, each week's code has run mostly on its own. A real machine needs one control loop that knows what it's currently doing and what should happen next: idle, scanning, classifying, sorting, returning home. Writing that out explicitly as named states, with clear transitions between them, keeps the logic readable as it grows — the alternative, a pile of nested if statements tracking implicit state in scattered variables, gets unmanageable fast. The status display here isn't a raw ST7789 breakout wired pin-by-pin: it's the Adafruit 1.3″ TFT Bonnet (a self-contained board with its own 240×240 screen, a 4-direction joystick with a click button, and two front buttons, all pre-wired internally to a fixed set of GPIO pins the moment it's connected). The trade-off for that convenience is normally position — a bonnet is designed to plug directly onto the Pi's header, sitting flush against it. This build wants the screen and joystick facing the operator at the front of the enclosure instead, so a 40-pin GPIO ribbon cable runs from a stacking header on the Pi (which passes every pin straight through, so the Motor HAT can still occupy the header as before) out to wherever the bonnet is actually mounted — same signals, different location. Writing timestamped events to a log file, meanwhile, is the difference between guessing what went wrong and actually knowing, once the machine is running unattended.
A complete, line-by-line interactive reference manual is available in the Software Architecture & Full Code Walkthrough Guide ↗.
possible_angles_slide = [45, 90, 135, 180, 225, 270]).main_loop.py (741 lines), skittles_classifier.py, motor_release.py, and skittle_centroids.json.Fit a stacking header (one that passes every pin through) onto the Pi's 40-pin header so the Motor HAT still has a header to sit on, then run the GPIO ribbon cable from the stacking header's pass-through pins out to wherever the bonnet will be mounted.
Plug the far end of the ribbon cable into the bonnet. Because every one of its pins is pre-assigned by the board itself — SPI for the screen, four GPIOs for the joystick directions, one for its click, two more for the front buttons — there's no breadboard wiring for the display circuit itself, unlike a raw ST7789 breakout.
Display a simple message before wiring anything else in — isolating display problems now saves a lot of confusion later.
Before writing code, draw the states (idle, scan, classify, sort, home) as circles and the events that move between them as arrows. This five-minute step prevents most of the tangled logic it's meant to avoid.
Every candy sorted by the machine follows a deterministic 8-step physical and algorithmic sequence inside next_scan_and_release():
def next_scan_and_release(now_bucket):
# 1. Update ST7789 LCD Framebuffer
status_display(disp, draw, image, width, height, text=["Scanning", "Skittles"])
# 2. Fire AS7341 Auxiliary Illuminator
sensor.led = True
# 3. Acquire 8-Channel Spectral Channels (F1 415nm -> F8 680nm)
the_scan = [
sensor.channel_415nm, sensor.channel_445nm, sensor.channel_480nm, sensor.channel_515nm,
sensor.channel_555nm, sensor.channel_590nm, sensor.channel_630nm, sensor.channel_680nm
]
# 4. Classify via Vector Sum-Normalization & Cosine Distance
pred, score, ranked = classify_reading(*the_scan)
# 5. Visual Telemetry (NeoPixels light up in predicted color) & Extinguish LED
light_the_bar(pred)
sensor.led = False
# 6. Translate Color to Target Radial Cup Angle
picked_bucket = which_slide(pred) # Yellow->1, Green->2, Purple->3, Orange->4, Empty->0
go_to_bucket(stepper_motor2, now_bucket, picked_bucket)
# 7. Index Wheel 90° to Discharge Candy down the Chute
rotate_to_angle(stepper_motor1, 90)
# 8. De-energize Stepper Coils (Heat Prevention) & Return State
stepper_motor1.release()
off_light_bar()
return picked_bucket, the_scan
When the operator selects 1 - Sort All, the machine enters an autonomous feedback loop. It runs continuously until the hopper is empty, detecting the end of the batch using a 3-consecutive-empty heuristic:
# The Autonomous 'Sort All' Repeat Loop:
empty_count = 0
now_bucket = 0
while empty_count < 3:
now_bucket, last_scan = next_scan_and_release(now_bucket)
if now_bucket == 0: # Empty pocket or unclassified reject
empty_count += 1
else:
empty_count = 0 # Valid candy found, reset counter
# Graceful termination
status_display(disp, draw, image, width, height, text=["Finished", "Sorting"])
stepper_motor1.release()
stepper_motor2.release()
Write a timestamped line to a text file on every state transition and every classification result, so a run can be reviewed afterwards without having watched it live.
Wrap a risky hardware call (like a sensor read) so a glitch produces a clear, named error instead of a crash:
class SensorError(Exception):
pass
try:
reading = read_sensor()
except Exception as e:
raise SensorError(f"Sensor read failed: {e}")
Test the complete idle → scan → classify → sort → home loop for one real Skittle.
raspi-config) and check the ribbon cable is fully seated at both ends and the right way round (pin 1 to pin 1) — the bonnet's own pins are fixed, so a wrong-side connection is the far more likely cause than a pin-assignment mistake.