|
Closed-loop line following on 1.5 cm tape. |
The ground station drawing the path from telemetry. |
A four-wheel mecanum robot that runs its own WiFi access point and serves 38 HTTP routes,
paired with a JavaFX ground station for live telemetry and runtime tuning.
- Line following steers in closed loop from two IR reflectance sensors, tuned over 20 logged trials on two tape widths.
- Obstacle stop uses ultrasonic time-of-flight with two thresholds, so the robot does not chatter at the trip point.
- Maze solving detects junctions by temporal filtering, then takes either random choices (O(J²)) or a queued route (at most O(J)).
- Kidnapped robot has two recovery strategies: drive straight until a line appears, or sweep outwards in expanding circles.
- Manoeuvres are five driving-test routines, two of which approximate an arc with a chain of short straight chords.
- Ground station is a JavaFX desktop client that receives telemetry pushed every 200 ms and re-tunes the controller at runtime.
Built for BCS1300 Project 1-1, Department of Advanced Computing Sciences, Maastricht University, December 2025 to January 2026. As submitted, the firmware is 1,231 lines of Arduino C++ and the client 1,726 lines of Java across 10 classes. Everything below was verified against that code before it was written down; where main has since diverged, the text says so.
Source baseline. Every
BackEnd.ino:Nand*.java:Ncitation on this page refers to the code as submitted, at thev1.0-submissiontag, where the firmware isCode/BackEnd/BackEnd.inoand the client isCode/GUI/src/main/java/. Append#L<n>to those links to reach a cited line.mainhas moved since: the firmware is 1,229 lines there, the client 1,819, the sources have been renamed tofirmware/andclient/, and the dead derivative block quoted below no longer exists.
Slide citations. The deck is not in the repository:
.gitignoreexcludes*.pptx, and the original is 343 MB, almost all of it embedded video. The video-free copy is published asPresentation.pdfon thev1.0-submissionrelease: 43 pages, one per slide, so a slide number below is a page number in that file. Every slide claim on this page can be checked against it.
An Adafruit Feather M0 with an ATWINC1500 radio drives four mecanum wheels through two motor-driver boards. Each motor takes a PWM pin for magnitude and a DIR pin for direction, plus a per-motor polarity flag (false on the left pair, true on the right), so move() can be written in signed speeds and the mirrored left/right wiring is corrected in exactly one place.
| Motor | PWM | DIR | Forward polarity |
|---|---|---|---|
| Front left | 6 |
5 |
false |
| Front right | 11 |
12 |
true |
| Back left | A4 |
A5 |
false |
| Back right | 9 |
10 |
true |
| Sensor / radio | Pins |
|---|---|
| IR reflectance, left | A1 analog, A0 digital |
| IR reflectance, right | A3 analog, A2 digital |
| HC-SR04 ultrasonic | 0 TRIG, 1 ECHO |
| ATWINC1500 (WiFi101) | 8 CS, 7 IRQ, 4 RST, 2 EN |
Pin assignments verified against BackEnd.ino:398-408. The analog IR outputs feed the controller; the digital ones answer the yes/no question "is there tape under this sensor", which is what junction and line detection are built from. Full write-up: Robot Movements.md.
The firmware is not a flat sketch. Sensors, motors and the radio are objects; Robot composes them and owns the state that the control loop and the HTTP layer both need.
The robot is the access point, not a client on someone else's network: it boots, calls WiFi.beginAP, pins itself to 192.168.4.1 and starts an HTTP server on port 80. The desktop client opens two connections to it and uses them asymmetrically. One is a raw socket that the robot writes to on its own schedule; the other carries one-shot control GETs.
flowchart LR
subgraph GS["Ground station · JavaFX 21"]
direction TB
UI["BotDataPane<br/>ParametersPane<br/>PathDisplayPane"]
RC["RobotController"]
SR["SensorReader"]
end
subgraph FW["Feather M0 · WiFi101 AP at 192.168.4.1:80"]
direction TB
SV["WiFiServer :80<br/>printHandler accepts two clients"]
RH["RequestHandler::handle<br/>38 GET routes"]
LP["loop()"]
end
subgraph HW["Hardware"]
direction TB
IR["2 × IR reflectance"]
US["HC-SR04"]
MT["4 × mecanum motor"]
end
UI --> RC
RC -->|control GET, one TCP connection per command| SV
SV -->|telemetry pushed every 200 ms| SR
SR --> UI
SV --> RH --> LP
IR -->|analog + digital| LP
US -->|pulseIn, 30 ms timeout| LP
LP -->|PWM + DIR| MT
Telemetry is pushed, not polled. printHandler() writes a ten-field plain-text block into the telemetry socket whenever millis() - lastSend >= 200, and the client's reader thread blocks on readLine() until it arrives. The client never asks, so the operator's display costs the robot no request-handling latency, and the telemetry rate is set by the robot whatever the GUI happens to be doing.
Both ends of the link: the ground station rendering live sensor and motor values, and the robot producing them.
The client is three tabs over a shared RobotController: Bot Commands (the key map), Parameters (every tunable, with range validation before anything is sent), and Path Display (the odometry canvas). Sensor and motor values are drawn over a photograph of the robot, in the position of the part they came from.
Mecanum wheels resolve four independently-driven rollers into three body-frame degrees of freedom, so the chassis translates in any direction without turning first.
|
Ten commanded directions: four cardinal, four diagonal, two rotations. |
The four wheel velocity vectors combine into Vx/Vy at angle α. |
Every movement route is one signed int[4] handed to robot.move(), which splits each element into magnitude and direction. Diagonals drive one pair of opposite corners and leave the other pair stopped; strafes drive both diagonals in opposition.
| Route | FL | FR | BL | BR |
|---|---|---|---|---|
/move/north |
+ | + | + | + |
/move/south |
− | − | − | − |
/move/east |
+ | − | − | + |
/move/west |
− | + | + | − |
/move/ne |
+ | 0 | 0 | + |
/move/nw |
0 | + | + | 0 |
/move/se |
0 | − | − | 0 |
/move/sw |
− | 0 | 0 | − |
/move/cw |
+ | − | + | − |
/move/ccw |
− | + | − | + |
/move/stop |
0 | 0 | 0 | 0 |
+ and − are ±baseSpeed, default 120 of 255 (BackEnd.ino:13). Verified against BackEnd.ino:674-732.
Two IR reflectance sensors straddle the tape. Their analog difference is the entire feedback signal, which keeps the control problem one-dimensional even though the drivetrain is omnidirectional.
The error e = L − R is low-pass filtered with an exponential moving average (α = 0.3) before it reaches the controller, and the output biases the two sides in opposition: vL = baseSpeed − correction, vR = baseSpeed + correction, each clamped to ±maxSpeed.
As submitted, the correction is the proportional term alone. pidControl() computed an integral accumulator and a derivative term, clamped the integral against windup, and then added neither to the output:
correction = kP * ef; // BackEnd.ino:1185, source comment: "PID correction (P-only effectively)"The block diagram above is the design; the line above is what ran. The derivative block has since been deleted from main as dead code, so dTerm is findable only at the tag; the integral accumulator is still there, and still unused. Why that happened, and the second, independent bug that would have prevented the tuned gains from reaching the robot even if it hadn't, is in What I'd do differently now.
Twenty trials, each one a hypothesis and an observation, run on real tape at μ = 0.5. Observations and conclusions are quoted verbatim from the log.
Case 1 used 3 cm tape with curves, 90° turns and zig-zags.
| Trial | Gains kP / kI / kD |
Speed clamp | Observed | Conclusion |
|---|---|---|---|---|
| 1 | 3 / 0 / 0 | ±100 | "Did not follow the line, kept moving forward" | kP too low |
| 2 | 300 / 0 / 0 | ±100 | "Follows the line, unstable oscillations" | Increase kD |
| 3 | 300 / 0 / 3 | ±100 | "Blocks all oscillations, doesn't move forward, left the line" | kD too high |
| 4 | 300 / 0 / 1.5 | ±100 | "Blocks most oscillations, robot moves forward slowly" | Decrease kD |
| 5 | 300 / 0 / 0.75 | ±100 | "Moves faster, follows the line" | Decrease kD |
| 6 | 300 / 0 / 0.4 | ±100 | "Normal behavior, follows the line" | Increase speed |
| 7 | 300 / 0 / 0.4 | ±140 | "Normal behavior, moves faster, follows the line" | Increase speed again |
| 8 | 300 / 0 / 0.4 | ±180 | "Not smooth oscillations, moves even faster, follows the line" | kI tryout |
| 9 | 300 / 0.05 / 0.4 | ±180 | "Smoother oscillations, follows the line" | Increase speed |
| 10 | 300 / 0.05 / 0.4 | ±255 | "Follows the line fast, unsmooth oscillations" | Decrease kD |
| 11 | 300 / 0.05 / 0.1 | ±255 | "Follows the line, unsmooth oscillations, slower speed" | Increase kP |
| 12 | 500 / 0.05 / 0.1 | ±255 | "Stops moving forward, doesn't follow the line" | Remove kD, decrease kP |
| 13 | 100 / 0.05 / 0 | ±255 | "Follows the line, moves fast, has smooth oscillations" | Decrease kP |
| 14 | 80 / 0.05 / 0 | ±255 | "Follows the line, moves fast, has smoother oscillations" | Decrease kP |
| 15 | 60 / 0.05 / 0 | ±255 | "Follows the line, moves fast, has smoother oscillations" | Decrease kP |
| 16 | 40 / 0.05 / 0 | ±255 | "Follows the line, moves fast, has smoother oscillations" | Can decrease kP further |
Case 2 used 1.5 cm tape on the same layout.
| Trial | Gains kP / kI / kD |
Speed clamp | Observed | Conclusion |
|---|---|---|---|---|
| 1 | 40 / 0.05 / 0 | ±255 | "Follows the line, but misses 90° turns and low angles" | Increase kP (needs more control on a smaller tape width) |
| 2 | 80 / 0.05 / 0 | ±255 | "Follows the line, but misses low angles" | Increase kP further |
| 3 | 120 / 0.05 / 0 | ±255 | "Follows the line, unsmooth oscillations" | Increase kD |
| 4 | 120 / 0.05 / 0.1 | ±255 | "Follows the line, smooth oscillations, speedy" | Can decrease kP, but this is a good setup for our case |
Halving the tape width demanded a higher proportional gain. The narrow line puts less sensor area over the tape, so the same lateral offset produces a smaller analog difference; at the gain that was comfortable on 3 cm tape the robot drove past the 90° corners (Case 2, trial 1: "misses 90° turns and low angles").
The settled configuration is recorded in the presentation, and its numbers differ from the trial log's. The testing slides give: kP = 225 on 3 cm tape, kP = 325 on 1.5 cm tape, both with kI = 0.05, kD = 0.1, at 100% speed. Slide 39 records kP = 225 on 1.5 cm tape at 60% speed as the conservative variant. Those are the values that hang together with the rest of the code. They sit beside the shipped firmware default of kP = 200 (BackEnd.ino:32) and make sense against the unnormalised error scale, since the raw sensor difference spans ±1023 and gains in the hundreds describe a deliberately saturating controller. The 40-120 pair the trial log converged on belongs to an earlier or differently-scaled experiment and cannot be reconciled with the shipped code.
No row in these tables ever ran an integral or derivative term. As submitted, pidControl() computed integralSum and dTerm and added neither to the output, and the HTTP parameter parser reads ?value= with toInt(), which truncates fractional values, so kI = 0.05 and kD = 0.1 would have arrived as 0 even from the GUI. The tables are a faithful record of what the P term did on real tape; the I and D columns are the intent. Both defects are dissected in What I'd do differently now.
Which gains actually shipped: the record disagrees with itself
| Source | 3 cm tape | 1.5 cm tape |
|---|---|---|
| Trial log, Line Following.md | kP 40, kI 0.05, kD 0 | kP 120, kI 0.05, kD 0.1 |
| Presentation, testing slides 38 / 13 | kP 225, kI 0.05, kD 0.1 @ 100% speed | kP 325, kI 0.05, kD 0.1 @ 100% speed |
| Presentation, slide 39 | kP 225, kI 0.05, kD 0.1 @ 60% speed | |
Shipped firmware BackEnd.ino:32-34 |
kP 200, kI 0, kD 0 | same |
Four sets of numbers, from three sources, for one robot. The 200-325 family is self-consistent: the error is the raw sensor difference, which spans ±1023, so gains in the hundreds are what a saturating controller looks like. The 40-120 pair belongs to an earlier or differently-scaled experiment and cannot be reconciled with the shipped code.
Every row above carrying a non-zero kI or kD records a gain that never took effect, for two independent reasons: the firmware computed those terms and did not sum them, and the HTTP parameter parser truncated fractional values to zero in transit, so kI = 0.05 arrived as 0. See What I'd do differently now.
|
Tracking through a curve. |
The test track: wide curves, 90° corners, zig-zags. |
|
The two thresholds: d and d + Δd.
|
Engage below d, release only above d + Δd.
|
An HC-SR04 measures time of flight; the firmware converts the echo width to centimetres with the standard duration / 58. A single threshold would make the robot stutter whenever a reading dithered across it, so the stop and the release use different thresholds:
// BackEnd.ino:897-909
void emergencyControl() {
int dist = robot.getDistance();
if (!robot.isEmergencyStopped() && dist <= dStop) {
robot.engageEmergencyStop();
}
if (robot.isEmergencyStopped() && dist >= (dStop + deltaD)) {
robot.releaseEmergencyStop();
}
}Both are settable at runtime: d via /emergency/stop?value=, Δd via /emergency/delta?value=. Firmware defaults are dStop = 1, deltaD = 1; the presentation's tested configuration was d = 10 cm at 60% speed on μ = 0.5. While the stop is latched, the HTTP layer refuses movement commands outright.
Two limits sit underneath this. pulseIn is given a 30 ms timeout and a timeout is reported as 999 cm, so a missing echo (too close, too oblique, unplugged) is indistinguishable from open space. And Robot::getDistance() returns that same 999 whenever line-following is active, which means the check above cannot fire while the PID loop is running. Both are dissected in What I'd do differently now.
|
Stopping under manual drive. |
The obstacle used for the test runs. |
Full write-up: Emergency Stop.md.
|
A north-east move, decomposed into four states: α = 45°, Vx = Vy. |
The canvas drawing the trail as the robot drives. |
The ground station reconstructs a trajectory from the four motor values in the telemetry stream. Each frame it inverts the mecanum mixing to recover body-frame velocities, rotates them into the world frame by the accumulated heading, and appends a point to a 3,000-sample trail:
// MecanumVisualizer.java:114-136
double vy = (FL + FR + BL + BR) / 4.0 * MOTOR_SCALE; // forward/backward
double vx = (FL - FR - BL + BR) / 4.0 * MOTOR_SCALE; // sideways
double omega = (-FL + FR - BL + BR) / 4.0 * MOTOR_SCALE; // rotation
heading += omega * OMEGA_SCALE;
double dx = vx * Math.cos(heading) - vy * Math.sin(heading);
double dy = vx * Math.sin(heading) + vy * Math.cos(heading);
posX += dx * MOVE_SCALE;
posY -= dy * MOVE_SCALE; // minus because the canvas y-axis points downThis is open-loop dead reckoning from commanded PWM, and it drifts. There are no wheel encoders on this robot. The values being integrated are what the firmware asked the motors to do rather than what they did, so wheel slip, the dead band at low duty, battery sag and the difference between four nominally identical gearboxes all accumulate silently into the heading, and the heading error then rotates every subsequent displacement. It reads well as a live indication of what the robot is doing and it will not survive being treated as a map. The three scale factors are empirical constants dragged until the drawing looked like the movement, which is why they are exposed in the Parameters tab.
Full write-up: Robot Path Display Logic.md.
Random choices cost O(J²) in the number of junctions J, and replacing the randomness with a queued route brings it down to at most O(J). This is the only complexity analysis in the project. Under random selection a junction can be re-entered any number of times, because the spin that leaves it may well point back at it; under a queued route each junction consumes exactly one decision and is never revisited.
Both modes need the same primitive first, which is knowing that a junction is under the robot at all.
|
L, R and their AND over time: a junction is a sustained double-true. |
inJunction() is an AND with a memory.
|
Both digital IR lines reading true simultaneously is necessary but nowhere near sufficient, because a 90° corner produces exactly that for a moment. So inJunction() counts consecutive double-true samples and only fires above JUNCTION_N = 10, resetting the counter the instant either sensor drops, and a latch suppresses re-triggering until the robot has left. The threshold is a trade-off, stated in the presentation as: the count must be high enough to skip a 90° turn, yet low enough to still catch a sideways T-junction.
Random mode: nudge to centre, spin randomly, then rotate until a line is reacquired.
Manual mode: a preloaded queue of decisions, one dequeued per junction. This is the exact queue in startManualMaze().
In both modes the junction handler stops the controller, nudges forward 300 ms to bring the wheels onto the crossing, acts, and restarts the controller. Random mode then spins for random(0, 10000) ms and calls the recovery routine; manual mode pops the next LEFT / FORWARD / RIGHT and turns until the corresponding sensor sees tape. Both also poll the ultrasonic on a 200 ms interval to catch a dead end, and they do it by pausing the controller for the duration of the reading, which is the time-sliced polling design that plain line-following never received.
Full write-up: Junction Detection.md.
Lift the robot and put it down somewhere else on the field. It has to find the line again with no map, no encoders and no idea where it is.
|
B, scanner sweep. Concentric circles, each larger than the last. |
A, naive. Drive straight until something turns up. detectLine() is L || R.
|
A drives forward at base speed until either digital sensor sees tape, then hands over to the controller for five seconds to settle onto it. It is one while loop, and on a bounded field with a closed track it works more often than it deserves to: a straight line from an arbitrary drop point stands a good chance of crossing the track somewhere.
B does not rely on that. It walks a circle as a chain of short chords, rotating for time1, strafing for time2, and testing for tape between every segment. When a full revolution finds nothing it grows the radius and goes round again: steps += 50, time2 += 20. With the shipped entry parameters (200, 20, 0) the first pass has time2 = 0, so it is a rotation scan on the spot; every later pass adds sideways travel and the search spirals outward, with passes taking roughly 4 s, then 10 s, then 18 s. The swept area grows while the robot stays near where it was dropped, which is the right property when the drop point is the only thing it knows.
Neither strategy has a timeout: while (!detectLine()); in A, and an unbounded outer loop in B. On a field with no line, A drives until the battery dies. That is a real defect and it is in the critique.
The presentation's comparison, at 60% speed with the controller running 3 s after acquisition, concluded that the comparison is determined solely by the robot's starting position. That is the honest outcome: neither strategy dominates, they fail in different places.
Full write-up: Kidnapped Robot.md.
The robot has no wheel encoders, so it cannot measure how far it has actually turned, which rules out closed-loop control of a curve. The project's answer came from Riemann sums: if you can approximate the area under a curve with a chain of rectangles, you can approximate a curved path with a chain of short straight segments.
|
The analogy: rectangles under a curve. |
The implementation: an arc as a chain of chords. |
Turn() is that idea, and it is short: drive forward turnTimeForwardStart, then alternate a short forward chord and a short rotation chord turnSteps times, then drive forward again to leave the curve straight. Nothing measures the angle; the shape is entirely a function of how many steps you ask for and how long each one lasts. At the shipped defaults of 37 steps of 70 ms forward plus 70 ms rotation, a full turn takes 7.18 s.
| Key | Manoeuvre | Route | What it does |
|---|---|---|---|
1 |
Timed reverse | /reverse/perform |
All four motors at −baseSpeed for timeReverse ms. Tested at 1 s, 50% speed. |
2 |
Stepped turn | /turn/perform |
The chord chain above. 37 steps was the tested U-turn, 18 the half-turn. |
3 |
Emergency approach | /emergency/pid |
Line-follows towards an obstacle, pausing the controller every 200 ms to take a reading, and stops inside dStop. |
4 |
Park in a box | /park/perform |
Line-follows until a junction, then drives forward parkTime ms into the box. Tested at 60% speed with a 1 s forward move. |
5 |
Three-point turn | /threeturn/perform |
Three arcs of the same chord chain, rotation held in one direction and the forward sign reversed between arcs. |
|
Three arcs, alternating direction. |
Park in a box: follow, detect the junction, then a timed push. The target box. |
Every timing constant here is editable from the Parameters tab while the robot is running: the step count, the three durations, and the two direction multipliers that mirror or reverse the curve. That is how the 37-step and 18-step values were found.
Full write-up: Turn.md.
Everything the robot can do is a GET against http://192.168.4.1. There are 38 routes, exhaustively extracted from RequestHandler::handle (BackEnd.ino:655-853).
1. Movement. Refused while the emergency stop is latched. All speeds are ±baseSpeed (default 120); the wheel mix per route is the sign table under Motion.
| Route | Method | Parameter | Default | Effect |
|---|---|---|---|---|
/move/north |
GET | Translate forward. | ||
/move/south |
GET | Translate backward. | ||
/move/east |
GET | Strafe right. | ||
/move/west |
GET | Strafe left. | ||
/move/cw |
GET | Rotate clockwise in place. | ||
/move/ccw |
GET | Rotate counter-clockwise in place. | ||
/move/nw |
GET | Diagonal forward-left. | ||
/move/ne |
GET | Diagonal forward-right. | ||
/move/sw |
GET | Diagonal backward-left. | ||
/move/se |
GET | Diagonal backward-right. | ||
/move/stop |
GET | All four motors to zero. |
2. PID. Start and stop line following. These two set the mode, stop the motors and reset the controller state, then return.
| Route | Method | Parameter | Default | Effect |
|---|---|---|---|---|
/pid/on |
GET | Start line following. While the emergency stop is latched this falls back to stopPID() instead (BackEnd.ino:739-743). |
||
/pid/off |
GET | Stop line following. |
3. Manoeuvres. The first seven run to completion inside the request handler, blocking the main loop until they finish. The three /maze/ routes are the exception: they only set a mode flag and return, and the maze logic then runs from loop().
| Route | Method | Parameter | Default | Effect |
|---|---|---|---|---|
/reverse/perform |
GET | Reverse in a straight line for timeReverse ms. |
||
/turn/perform |
GET | Stepped arc turn from the six turn* parameters. |
||
/threeturn/perform |
GET | Three-point turn. | ||
/emergency/pid |
GET | Approach the nearest obstacle under closed-loop control and stop. | ||
/park/perform |
GET | Follow to a junction, then park forward. | ||
/kidnap/a |
GET | Straight-line search. | ||
/kidnap/b |
GET | Expanding-circle search, invoked as kidnappedB(200, 20, 0) (BackEnd.ino:779). |
||
/maze/on/manual |
GET | Enter manual maze mode. | ||
/maze/on/random |
GET | Enter random-choice maze mode. | ||
/maze/off |
GET | Leave maze mode. |
4. Tuning. Every route that takes ?value= (15 routes; the value parser is String::toInt() at BackEnd.ino:808).
| Route | Method | Parameter | Default | Effect |
|---|---|---|---|---|
/pid/kp |
GET | value |
200 |
Proportional gain. |
/pid/ki |
GET | value |
0 |
Integral gain, computed but never summed (see Line following). |
/pid/kd |
GET | value |
0 |
Derivative gain, computed but never summed. |
/pid/base |
GET | value |
120 |
Nominal drive speed, PWM. |
/pid/speed |
GET | value |
±200 |
Symmetric output clamp: maxSpeed = abs(value), minSpeed = −abs(value). |
/emergency/stop |
GET | value |
1 |
Stop distance d, cm. |
/emergency/delta |
GET | value |
1 |
Release hysteresis Δd, cm. |
/reverse/time |
GET | value |
0 |
Timed-reverse duration, ms. |
/turn/turnSteps |
GET | value |
37 |
Chords in the arc. |
/turn/turnXDirection |
GET | value |
1 |
Forward-chord sign. |
/turn/turnYDirection |
GET | value |
1 |
Rotation-chord sign. |
/turn/turnTimeForwardStart |
GET | value |
1000 |
Entry and exit straight, ms. |
/turn/turnTimeForwardCorner |
GET | value |
70 |
Forward chord, ms. |
/turn/turnTimeRotationCorner |
GET | value |
70 |
Rotation chord, ms. |
/park/time |
GET | value |
0 |
Forward push into the box, ms. |
Two properties of the tuning routes will catch you out. The value parser truncates, so ?value=0.05 sets zero. And a request with no value= at all sets the parameter to 0 rather than being ignored, so visiting /pid/base in a browser sets baseSpeed = 0 and makes every subsequent movement command a no-op. Motors already turning keep the PWM they were last given until something calls move() again.
Every route is GET (all 38 handlers match GET /…); none of them accept any other method. The server is the robot's own access point at http://192.168.4.1, port 80 (WiFi.beginAP, BackEnd.ino:115; the client points at the same address, hardcoded as submitted, see SensorReader.java:15 and RobotController.java:288, and on main a default that -Drobot.host=… overrides at launch, RobotConfig.java:14-22). While the emergency stop is latched, routes containing /move/ are refused at the gate (BackEnd.ino:665), so driving stops and stays stopped. Manoeuvre routes are not blocked, because the gate matches only /move/.
Ground station key map, showing what the client actually sends:
| Key | Sends |
|---|---|
W A S D |
/move/north · /move/west · /move/south · /move/east, held |
W+A W+D S+A S+D |
/move/nw · /move/ne · /move/sw · /move/se |
Q · E |
/move/ccw · /move/cw |
P |
toggles /pid/on and /pid/off |
R |
toggles /move/south and /move/stop, reverse held on |
T · Y |
toggles random · manual maze mode |
N · M |
/kidnap/a · /kidnap/b |
1 to 5 |
the five manoeuvres above |
Movement keys send on press and on release, and are ignored entirely while line following is on. Two labelling mismatches surfaced while writing this table, both in the client and neither affecting the firmware: the in-app command list labels Q as "Rotate Clockwise" although it sends /move/ccw (CommandsPane.java:35-36 against RobotController.java:239-240), and the Parameters tab's two turn-direction fields carry each other's X/Y label (ParametersPane.java:76-77). The table describes what the code does; the two labels are wrong.
Full instructions, including board and library setup: docs/BUILD.md.
git clone https://github.com/qnicondavid/self-driving-bot
cd self-driving-bot/client
mvn javafx:runThe client needs JDK 17+ (JavaFX 21). The firmware targets the Adafruit Feather M0 with the WiFi101 library; flash firmware/BackEnd/BackEnd.ino, then join the access point the robot broadcasts on boot and the client will connect to 192.168.4.1 on its own.
mvn test in client/ runs a JUnit 5 suite (mvn javafx:run to launch the GUI itself). It covers the pure logic only: odometry maths, telemetry line parsing, key-to-route mapping, parameter validation and host config, all extracted so they run headless with no JavaFX toolkit boot and no robot on the network. It does not test the GUI itself, does not exercise any HTTP or socket I/O against a real robot, and does not cover the firmware at all.
Demo videos of every capability and a video-free copy of the presentation (Presentation.pdf, 43 pages, one per slide) are attached to the v1.0-submission release. The seven technical write-ups are in docs/reports/; the final report is docs/final-report.pdf.
These are the five things I would change about the firmware I shipped in January. The robot was a loaned university unit and has been returned, so nothing below was re-flashed or re-run; this is analysis, not a changelog. Line numbers are BackEnd.ino at the v1.0-submission tag. Four of the five are things I verified by reading the code; the fifth is an inference, and I say so in its paragraph.
The emergency stop could not fire while line-following, and we knew it. Robot::getDistance() returns the sentinel 999 whenever PID is active (BackEnd.ino:387-391). Five places call it, and emergencyControl() (:897-909) is the only one that reads it without pausing the controller first. The three time-sliced pollers below stop the PID before reading, and telemetry (:313) reports the sentinel, so the operator's display reads a confident "999 cm" with nothing to say the sensor is bypassed. 999 <= dStop is never true, so a stop could not engage; 999 >= dStop + deltaD is always true, so starting the PID released an already-latched stop. We did identify this at the time. Presentation slide 6 states "PID: Not Segmented (UR distance not printed when PID is ON)", and slide 20 presents the fix we designed, time-slicing the loop and polling the sensor between PID segments:
The slide-20 design: pause the PID, poll the sensor, resume or stop.
That design is implemented, but only inside mazeSolving(), manualMazeSolving() and emergencyPID(), each of which calls stopPID() before reading distance (:1062-1064, :973-975, :472-473). Plain /pid/on never received it. The accurate lesson is a scoping gap, not an oversight: the safety design existed and worked where we remembered to apply it. The fix is to apply it on the path plain line-following takes.
The controller was documented as a PID and shipped as a P, twice independently. pidControl() computes integralSum (:1166) and dTerm (:1179-1181), clamps the integral against windup, and then sums neither: correction = kP * ef (:1185), with the source comment on :1184 reading "PID correction (P-only effectively)". Independently, the HTTP parser reads ?value= with String::toInt(), which is atol (:808): atol("0.05") == 0, so the tuned kI = 0.05 could never have reached the robot even if the summation had been there, and the two bugs masked each other. The nastiest corner is one nobody hit: Double.toString(0.0001) is "1.0E-4" and atol reads the mantissa, so entering Ki = 0.0001 would have set kI = 1, a 10,000× amplification waiting to fire the moment I and D were wired in.
kP = 200 against a ±1023 error makes the controller bang-bang. e = leftSensor − rightSensor (:1162) spans ±1023 (the sensors are constrained to 0-1023 at :1158-1159), and correction = 200 · ef against a ±200 output clamp (:1191-1192) saturates on any error beyond about one LSB. The error needed normalising to ±1 before the gains meant anything. The tuning campaign still converged because for line-following the sign of the error matters more than its magnitude: a saturating controller steers hard in the right direction, and the EMA filter (α = 0.3, :1163) smooths the chatter into a tolerable wobble. What the sweep was really tuning was a relay, which is why it produced "oscillations" rather than a proportional response, and why the four conflicting gain sets in the tuning tables all "worked".
Manoeuvres blocked the whole robot. delay() and while(1) inside RequestHandler::handle suspend emergencyControl() (:1226), telemetry and command intake (:1227) for the duration of a manoeuvre; on SAMD, delay() only reaches the empty weak yield(), so there is no background servicing at all. The fix is a manoeuvre state machine stepped from loop(). The sharpest consequence: emergencyPID() (:463-479) could not terminate at all with the default dStop = 1 (:41). Its only exit is getDistance() < dStop (:473), which requires an echo shorter than 58 µs, inside the HC-SR04's blind zone, where there is no echo, which the firmware maps to the same 999 (:184). Pressing 3 in the GUI started a stutter-approach that never ended; only a power cycle recovered it.
Sockets are never explicitly closed, and this is the one item I am not certain about. The only .stop() calls in the firmware stop the four motors (:283-286); no client.stop() exists anywhere. WiFi101's socket table holds 7, and the client opens a fresh TCP connection per command. The server answers every one with Connection: close (:850), so the teardown is real even though nothing is ever stopped on the firmware side. Whether the library reclaims the socket-table entry depends on how it handles the peer closing first, and I cannot test it. This is inference, not something I verified on hardware. It is the first thing I would check with a bench and a packet counter.
The robot was a loaned university unit and has been returned, so these are proposed fixes, not validated ones.
All five are implemented on fixes/post-mortem, one commit per defect, with a branch README recording what each change would need on a bench before anyone trusted it. Every commit there compiles for the Feather M0 and none of it has run on the robot, so the branch is deliberately not merged.
A three-person project. At the v1.0-submission tag the repository holds 114 commits.
| Author | Commits | Contribution |
|---|---|---|
| Nicon-David Milandru | 89 | All firmware: git blame attributes 1,223 of BackEnd.ino's 1,231 lines. The entire JavaFX client, all 10 classes, 1,726 lines, 100%. Four of the seven short reports: Line Following, Emergency Stop, Robot Movements, Robot Path Display Logic. |
| DannyGing | 21 | Kidnapped Robot.md (152 of 153 lines) and Turn.md (52 of 53). |
| Vito-Martignano | 4 | Junction Detection.md (96 of 97 lines). Also one December firmware commit (+935 / −268 across two files) which January's object-oriented rewrite superseded; git blame still attributes 8 lines of the shipped firmware to it, seven of them blank lines or closing braces. |
Figures are from git shortlog -sne v1.0-submission and git blame -w v1.0-submission, not from memory. The reports were later edited to correct code snippets that had drifted from the firmware; the attribution above is measured at the submission tag, before those edits.
MIT. See LICENSE.