Architecture

How the pieces talk

Six ROS 2 nodes, one 115200-baud serial line and four Arduino Megas. The interesting design decision is that autonomy never gets its own channel — it impersonates the driver.

01 — Dataflow

Nodes and topics

The detector never talks to hardware and the bridge never touches the camera. They meet on two Float32 topics, which makes either side independently testable — you can publish /value_x by hand and watch the robot move.

PS4 controller /dev/input/js0 RealSense D455 640×480 @ 30 joy_node deadzone 0.1 · 20 Hz realsense_basket_ detector YOLO last.pt depth sample 11×11 2× PIDController /joy /value_x /value_y /basket_distance /detection_image smart_drive_ bridge 20 Hz timer mode arbitration Drive Mega /dev/ttyACM1 Shoot Mega separate port sensor_msgs/Joy std_msgs/Float32 "axes;buttons\n"
Orange is the autonomy path. /basket_distance and /detection_image are published for humans and rosbag, not consumed by the bridge — the control signal is only the two Float32 topics.
02 — Protocol

One line, two halves

There is no framing, no checksum and no acknowledgement. A packet is the axis list, a semicolon, the button list and a newline. The Arduino accumulates printable ASCII until it sees \n, then splits on the first ;.

packet -0.42,0.00,0.13,-0.05,0.00,0.00,0.00,0.00 ; 0,0,0,0,0,0,0,1,0,0,0,0,0,0,0 \n axes — float, index-addressed buttons — int 0/1 axes[0..1] translate X / Y axes[2..3] PID inject axes[4..5] rotate CCW / CW b[0..3] shoot presets b[7] auto b[8] permission b[9] override Guard: only bytes 32–126 are buffered, so line noise below space is silently dropped. Failure mode: a packet with no ';' is discarded; a truncated packet still parses, leaving stale values in the unfilled slots.
Axis slots 2 and 3 are the seam. In manual mode they carry whatever the pad reports; in auto mode smart_drive_bridge overwrites them with PID output before the line is written.
Why impersonate the joystick?

Because the firmware then needs no autonomous code path at all — auto_mode only changes which axes the motor mixer reads. The cost is that the driver's real stick values keep streaming underneath, so the arbitration has to be exactly right on both ends.

03 — Drive

The decision ladder

controlMotors() is a single if/else chain. It is not a mixing matrix: each branch writes an explicit direction bit and PWM value per wheel. Order matters — the first matching branch wins, and rotation is only reachable when translation is exactly zero.

packet parsed Override? boots as true all PWM = 0 yes no sign(x), sign(y) → quadrant 4 diagonal cases · x,y scaled by movement_speed 180 one axis zero → 4 straight cases drives only the wheel pair on that axis rotAnticlock / rotclock > 0 all four wheels at rot_speed 100 else: stop auto_mode → pid() axes[2], axes[3] only if nothing above matched
Autonomy sits at the bottom of the ladder (Drive_with_auto.ino:236). Any non-zero stick input on axes 0–1 or a held rotation trigger takes priority — which doubles as an implicit driver takeover.

Wheel layout

Four wheels on a cross: front and back share the X pair, left and right share the Y pair. In the diagonal branches all four run; on a straight move only one pair is energised.

M1 front · pin 10 M3 back · pin 6 M2 left · pin 8 M4 right · pin 4 x pair y pair +x = left, +y = front
Pin numbers shown are bot 1. The sign convention is stated in the firmware comment at Drive_1_for_new_controllers.ino:108 — “considering Left as positive and front as positive”.
04 — Vision

Box centre to range

Depth is aligned to colour before anything else, so a pixel in the YOLO box maps directly to a depth sample. Rather than trusting one pixel, the node takes an 11×11 patch at the box centre, drops zero returns, sorts, trims the extremes and averages the rest.

align() depth → colour YOLO class 0 only conf ≥ 0.50 depth patch 11×11 at centre sort · trim · mean horizontal error −(cx − 320) / 320 +ve = hoop is left range error d − target_ground target = 3.284 m 2× PID Kp 0.5, Ki 0, Kd 0 clamp ±0.5
Note the target. target_distance is 5.726 m, but the value actually compared against is 5.726 × cos(55°) = 3.284 m — while the measured side of that comparison had its cosine correction commented out. See finding 1.
05 — Alignment

The closed loop, drawn as a loop

setpoint 3.284 m, 0 px PID Kp·e (Ki=Kd=0) clamp ±0.5 axes[2], axes[3] serial @ 20 Hz pid() mixer ×60 translate ×40 rotate robot pose range, bearing camera + YOLO 30 Hz measurement measured range / offset e r
Two rates in one loop. Vision updates at 30 Hz, the serial writer at 20 Hz, and the firmware re-evaluates on every received line — so the plant sees the most recent PID output, not every one of them.
Ki and Kd are both zero

As committed, both controllers are pure proportional — the integral and derivative machinery in PIDController runs but contributes nothing. With Kp = 0.5 and a ±0.5 clamp, the loop saturates for any error above 1.0, so approach speed is constant until the last metre. You can feel this in the alignment simulator.

06 — Shooting

A permission-gated state machine

An autonomous shot cannot start on vision alone. Override must be off and a human must have granted permission with button 8; the permission is then consumed by the shot and must be re-granted for the next one.

idle stage 0 gate !Override && perm spin-up stage 1 · 2000 ms shooting stage 2 · 3000 ms complete stage 3 · reset perm permission consumed — press button 8 again for the next shot refused: “SHOOTING PERMISSION REQUIRED” flywheels at preset PWM servo 90° → 0° → 90°
Blocking inside a state machine. The servo sweep in Rotate_angle() steps 5° every 15 ms inside a while loop, and shoot() adds a 1000 ms delay() — during which no serial is read. See finding 6.
07 — Reference

Pin maps and button assignments

Drive board

WheelPositionBot 1 PWMBot 1 DIRBot 2 PWMBot 2 DIR
M1Front1022524
M2Left826728
M3Back630932
M4Right4341136

Shooter board

FunctionBot 1 PWMBot 1 DIRBot 2 PWMBot 2 DIR
Right flywheel9221020
Left flywheel112686
Angle motor1413418
Trigger servosignal 5, rest 90°signal 16, rest 100°
Speed presets100 / 120 / 15050 / 80 / 120

Controller map

IndexBoardActionBehaviour
b0 / b1 / b3ShootSpeed preset 1 / 2 / 3Latches one mode, clears the others and resets the trim
b2ShootStopMotors off, servo back to rest
b7DriveAuto-align toggleDebounced 200 ms, toggled on both host and firmware
b8ShootShoot permissionRefused while Override is on; consumed after each shot
b9BothOverrideboots ON Robot is inert until the driver clears it
b10ShootManual fireOnly in manual mode; 250 ms pre-delay
b11 / b12ShootTrim ±10 PWMUnbounded — no clamp on the accumulated trim
b13 / b14ShootAngle down / upHeld, blocked while any shoot mode is latched