Sorter — Under the hood
Sorter architecture
ExplanationWhere things live in the Sorter V2 local software, and why. Read this before changing any module under `software/sorter/backend/`.
The local software is two processes:
| Process | Path | Responsibility |
|---|---|---|
| Python backend | software/sorter/backend/ |
Owns hardware, vision, authoritative state. Listens on :8000. |
| SvelteKit UI | software/sorter/frontend/ |
View + command emitter. No machine state. Vite on :5173. |
./dev.sh boots both. The UI proxies API calls to the backend.
Backend boot
Entry point: software/sorter/backend/main.py. It wires together:
global_config.py— logging, profiler, runtime stats. Passed everywhere asgc.irl/config.py—IRLConfigis the declarative hardware config;IRLInterfaceis the live hardware (servos, steppers, chute, carousel).vision/vision_manager.py— camera capture threads + detection pipelines.sorter_controller.py— lifecycle wrapper. States:INITIALIZING / READY / PAUSED / RUNNING.server/api.py— FastAPI + WebSocket. Runs in its own thread.
The main thread runs a tick loop. When the controller is RUNNING, each tick calls coordinator.step().
The coordinator and three subsystems
coordinator.py is thin. It holds three independent state machines under subsystems/ and ticks them in order:
def step(self):
self.feeder.step()
self.classification.step()
self.distribution.step()
Cross-subsystem communication is one tiny dataclass: SharedVariables (classification_ready, distribution_ready, carousel, chute_move_in_progress). That is the entire shared API.
| Subsystem | States | Job |
|---|---|---|
| Feeder | IDLE, FEEDING |
Watches the MOG2 channel detector; moves detected parts from a C-channel to the carousel. Skipped entirely in manual_carousel mode. |
| Classification | IDLE, DETECTING, ROTATING, SNAPPING |
Rotates the carousel until a part is found, then captures top/bottom frames and runs the detection algorithm. The slow path — most OpenRouter latency lives here. |
| Distribution | IDLE, POSITIONING, READY, SENDING |
Maps the classified part → category via SortingProfile, asks Chute to move to the matching bin angle, releases the part. |
chute.py is worth reading if you care about calibration: bin angles are computed open-loop from first_bin_center + section * 60° + bin * bin_width. No closed loop after homing.
Vision pipeline
vision/vision_manager.py is the most complex single module. It owns:
- One
CaptureThreadper camera, holding the latest frame. FeederAnalysisThread— runs MOG2 against the feeder camera.ClassificationAnalysisThread— runs the configured detection algorithm against the classification cameras.- A ~10 FPS preview encoder that pushes frames to the UI over WebSocket.
Two camera layouts:
| Layout | Cameras |
|---|---|
default |
One feeder camera covering all C-channels + carousel; one or two classification cameras. |
split_feeder |
One camera per C-channel + carousel; classification cameras separate. |
Detection algorithms are plugin-style (detection_registry.py). Defaults: MOG2 (feeder), heatmap diff (carousel), gemini_sam (classification — remote Gemini call + SAM2 post-processing).
Machine platform
machine_platform/ is the layer that lets the same higher-level code run on different hardware. The key type is MachineProfile:
@dataclass(frozen=True)
class MachineProfile:
camera_layout: str
feeding_mode: str
servo_backend: str # "pca9685" or "waveshare"
stepper_bindings: Mapping[str, str]
stepper_direction_inverts: Mapping[str, bool]
boards: tuple[BoardSummary, ...]
capabilities: MachineCapabilities
Built from auto-discovered control boards over USB serial + the user’s machine.example.toml. stepper_bindings is the escape hatch for wiring mistakes — rebind a logical name like carousel to whichever physical channel the motor is actually wired to.
Configuration layers
Four sources, in increasing user-editability:
| Layer | Where | Owns |
|---|---|---|
| Code defaults | Python constants | Things that are the same on every machine. |
| TOML | machine.example.toml (env: MACHINE_SPECIFIC_PARAMS_PATH) |
Servo angles, layer layout, chute calibration, camera indices, feeding mode. |
| Blob storage | software/sorter/backend/blob/*.json (via blob_manager) |
Detection configs, classification polygons, Hive credentials. Most of this is UI-edited. |
| SQLite | local_state.sqlite |
API keys, recent known objects, lifecycle state across restarts. |
The sorting profile (sorting_profile.json) is technically a blob but is edited through its own UI because it changes during a run. See profile reference.
UI
SvelteKit + Vite. REST for commands and config; WebSocket (/api/ws) for events (frames, transitions, detections, runtime stats). The UI owns no authoritative state — if two tabs disagree, the missing piece is a WebSocket event, not a frontend cache.
Routes mirror operator mental model, not backend module layout: /setup, /, /profiles, /bins, /classification-samples, /settings, /styleguide.
Restart safety
process_guard.py enforces one backend per machine — so a crashed ./dev.sh cannot race the old serial-port owner. Restart always boots into INITIALIZING; it takes an explicit start then resume from the UI to reach RUNNING. Parts mid-classification at shutdown are lost — the system does not try to resume an in-flight part.
Where to look first
| Symptom | Start here |
|---|---|
| Nothing detected in the feeder | vision/mog2_channel_detector.py, subsystems/feeder/feeding.py |
| Carousel spins forever | subsystems/classification/rotating.py, vision/classification_detection.py |
| Part lands in the wrong bin | subsystems/distribution/chute.py, sorting_profile.py |
| UI state ≠ reality | server/api.py, message_queue/handler.py, the WebSocket events |
| First-boot config refuses to save | blob_manager.py, machine_platform/machine_profile.py |
User-facing version of this list: Sorter troubleshooting.