Four firmware variants and six ROS nodes, read side by side. Line references point at the committed source — every claim below is checkable against the file it names.
| Path | Role | Notes |
|---|---|---|
| arduino_codes/ | Production firmware | Drive ×2, Shoot ×2, plus the two *_with_auto variants that add the autonomous path |
| ros_depthCamera/src/basketball_robot/ | Vision + bridge package | The live stack: detector, PID, serial bridge, launch files |
| joy_py/ · joy_serial_ws/ | Earlier joystick bridges | Near-duplicates of each other; joy_py is the newer of the two |
| Detection/ | Standalone experiments | HSV colour-threshold detector and the trained last.pt |
| esp32_project_env/ | Committed virtualenv | Also holds an older set of shoot controllers under Servo_codes/ |
| test_d455.py | Camera smoke test | Streams 30 frames, prints centre-pixel range |
joy_serial_ws → joy_py → ros_depthCamera is the actual
chronology, and all three still contain a joy_serial_bridge package. Only the last
one is wired to the camera. When something behaves unexpectedly, checking which bridge is
running is the first thing worth doing.
Owns the camera, the model and both controllers. It publishes four topics but only two of them close the loop. The depth sampling is the nicest part of the file — an 11×11 patch, zero returns discarded, sorted, extremes trimmed, mean taken:
# realsense_basket_detector.py:292 distances = [] sample_size = 5 for dx in range(-sample_size, sample_size + 1): for dy in range(-sample_size, sample_size + 1): px, py = center_x + dx, center_y + dy if 0 <= px < 640 and 0 <= py < 480: d = depth_frame.get_distance(px, py) if d > 0: distances.append(d) distances.sort() if len(distances) > 3: distances = distances[1:-1] # trim outliers distance = sum(distances) / len(distances)
Publishes /basket_detection, /basket_distance, /value_x,
/value_y and, when cv_bridge is importable, /detection_image.
The arbiter. It holds the last Joy message and the two PID values, and on a 20 Hz timer
decides which to serialise. The staleness guard is a good instinct — if no joystick message has
arrived in 500 ms it sends nothing at all:
# smart_drive_bridge.py:121 if time.time() - self.last_joy_time > 0.5: return with self.serial_lock: if self.auto_mode and not self.override: fake_axes = list(self.joy_data.axes) if len(fake_axes) >= 2: # ← guards 2, writes 3 fake_axes[2] = self.pid_x fake_axes[3] = self.pid_y
On shutdown it does the right thing: it writes a packet of zeroed axes with the override button forced high, waits 100 ms, then closes the port — so a Ctrl-C stops the wheels rather than leaving the last command latched.
The drive firmware plus an autonomous branch. Two independent debounced toggles — override on
button 9, auto on button 7 — both with a 200 ms window and a press/release latch. The ladder
itself is covered in the architecture; what matters here is
that pid() is only reached when every translation and rotation test above it fails.
The largest single file. It accepts two different input languages on the same port — line-oriented ROS commands and joystick packets — and dispatches on prefix:
// _shoot_with_auto.ino:113 if (inputBuffer.startsWith("SHOOT:") || inputBuffer.startsWith("AUTO_MODE:") || inputBuffer.startsWith("STOP_SHOOT")) { processRosCommand(inputBuffer); } else { processPS4Data(inputBuffer); // "axes;buttons" }
No node in the repository ever sends SHOOT: or AUTO_MODE:. The command
interface is implemented and reachable, but nothing upstream drives it — autonomous shots are
currently only triggerable by hand over a serial monitor.
An earlier detector with one idea the live one lacks: it degrades instead of dying. If
pyrealsense2, cv2 or ultralytics fail to import it drops into
a simulation thread that publishes plausible values, so the rest of the graph can be exercised on a
laptop with no camera attached. Worth porting forward.
Ordered by how much they affect behaviour on the field. Severity is my read; the line references are facts.
target_ground_distance is computed as 5.726 × cos(55°) = 3.284 m
(realsense_basket_detector.py:122), but the matching cosine on the
measured side is commented out (:311):
ground_distance = distance #* math.cos(math.radians(self.shooting_angle))
So a slant range is compared against a horizontal projection. The loop converges on 3.28 m of measured range, not the 5.726 m the parameter names. Either restore the cosine on line 311 or drop it from line 122 — but the two must agree.
compute() mutates previous_error, integral and
last_time. It is called once to produce the published value
(:372) and then a second time, on the same frame, purely to build an
overlay string (:414):
pid_text = f"PID Control: X={self.x_pid.compute(horiz):.3f}, Y={self.y_pid.compute(error):.3f}"
The second call sees dt ≈ 0, which inflates the derivative term and double-counts the
integral. It is currently harmless only because Ki and Kd are both zero — the moment
anyone tunes them, the loop will behave differently from the numbers on screen. Cache the
outputs and render those.
In controlMotors(), M2/M4 are the pair driven by the forward axis and M1/M3 by the
lateral one. But pid() sends axes[2] — the horizontal alignment error —
to M2/M4 (Drive_with_auto.ino:311), and the range error to M1/M3.
The two corrections are swapped: a hoop off to the left makes the robot drive backwards.
Watch it happen in the alignment simulator.
pid() is an if/else chain that tests axes[3] only after both
axes[2] < 0 and axes[2] > 0 have failed — that is, only when
axes[2] is exactly 0.0. A float coming off a proportional controller is
essentially never exactly zero, so distance correction never executes. And when it does, those
branches compute M2/M4 from axes[2] rather than axes[3]
(Drive_with_auto.ino:328) — the wrong variable.
check_override() latches on button 9 but clears the latch on button 4
(Drive_1_for_new_controllers.ino:246):
if (buttons[9] == 1 && last_override_button == 0 && !toggled_on_this_press) { ... } if (buttons[4] == 0) { toggled_on_this_press = false; } // ← 4, not 9
In practice button 4 usually sits at 0, which masks the bug — but the two are swapped in
Drive_2 (toggles on 4, clears on 9), so the same physical button does different
things on the two robots. For a safety control, that is the one place you want no ambiguity.
Rotate_angle() sweeps the servo 5° at a time with delay(15) inside a
while loop, and shoot() adds a flat delay(1000)
(_shoot_with_auto.ino:442). A full fire sequence blocks the main loop
for well over a second while the host keeps writing at 20 Hz. The Mega's 64-byte receive buffer
holds roughly one packet, so the rest are dropped mid-line — and a truncated packet still parses,
leaving stale values in the slots it never reached.
Arduino's map() takes long arguments, so the float trigger value is
truncated toward zero before the mapping runs
(Drive_1_for_new_controllers.ino:96). The result is that
rotAnticlock can only ever be 0, 50 or 100 — proportional rotation is not available,
and any partially-held trigger gives exactly half speed.
There is a sharper edge here: if the joystick driver reports the triggers as 0.0
before their first press — which several Linux drivers do — then map() returns 50 with
the sticks centred, and the robot rotates on its own as soon as Override is cleared. Multiply by
1.0 in floating point instead.
The auto-mode toggle tracks button 7 throughout, then stores the override button's state as its "previous" value (Drive_with_auto.ino:289):
last_auto_toggle = buttons[9]; // should be buttons[7]
Edge detection against an unrelated signal — auto mode can toggle on a frame where button 7 was already held, if button 9 happened to change.
smart_drive_bridge.py:131 guards on len(fake_axes) >= 2
and then assigns indices 2 and 3. Any pad reporting two or three axes throws inside the timer
callback, which kills the 20 Hz writer while the joystick keeps publishing — the robot holds its
last command. The guard should be >= 4.
For the same three named functions, joy_to_ros.py uses indices 7, 8 and 9 while
joy_to_ros_safe.py uses 6, 7 and 8 — and both label them "Button 7/8/9" in their log
strings. Whichever is running silently shifts every control by one.
drive_serial_bridge.py looks for basketball_hoop.pt, falls back to stock
yolov8n.pt, and then matches on COCO class names — "sports ball",
"person" (drive_serial_bridge.py:296). Meanwhile the
trained weights that actually detect the hoop, last.pt, sit next to it in the package.
The live node uses them; this one never will.
Related: the live node hardcodes /workspace/Robocon25_codes/… as its default model
path (realsense_basket_detector.py:90) — a directory name that does
not match this repository. It only runs where that absolute path happens to exist.
/basket_detection publishes the distance
error under a field named ground_dist
(realsense_basket_detector.py:358).and/or without parentheses in the button-7 edge test; it parses as
(A and B) or C, which is probably not what was meant.Shoot_1 reads buttons[6] where
Shoot_2 reads axes[6] for the same angle control.analogWrite.cv2.imshow + waitKey run inside the ROS
timer and inside a worker thread elsewhere; on some builds that is a hard crash rather than a
warning.esp32_project_env/) and 100+ colcon
build logs are committed — roughly 270 MB of the clone. A .gitignore for
log/ build/ install/ and the venv would cut that to a few MB.
Swap the pairs in pid() and turn the exact-zero test into a threshold so both
corrections run every tick. This is the difference between a loop that centres and a loop
that arrives.
One line, either side. Until setpoint and measurement use the same geometry, every distance
number in the logs is off by a factor of cos(55°).
The stage machine already exists — processAutonomousShoot() is non-blocking. Move
the servo sweep into it and the firmware stops dropping packets during a shot.