00 Competition Quick Reference
Run Time
8 min
Hard cutoff by officials
Measurements
12
4 water + 8 soil
Closing Night
Fri 31 Jul
5 – 9 pm
Total Points
100
Six categories
Scoring Table (table totals 100; authoritative)
| Category | Description | Points | Strategy |
| Cat 1 | Autonomous operation & measurements | 30 | Complete all 12 samples autonomously |
| Cat 2 | Data output (logs, displays) | 10 | WiFi JSON + visualizer.html demo |
| Cat 3 | Build quality | 5 | Clean wiring, secure mounts |
| Cat 4 | Innovation | 10 | Hybrid RC + dead-reckoning 2.0 + visualizer |
| Cat 5 | Aesthetics | 5 | NeoPixel LEDs + tidy chassis |
| Cat 6 | Closing Night Case Competition | 40 | Data story + policy recommendation |
| TOTAL | 100 | Cat 6 is single largest category |
Cat 6 = 40 pts (highest single category). A robot that works 80% + a great case presentation beats a perfect robot with a weak presentation. Budget 1 hour for Day 2 prep.
8-Minute Time Budget
| Activity | Time |
| INIT + auto-calibration (place at center) | 5 s |
| Per-sector: 6 strips × 750mm at 100mm/s | ~75 s |
| Per-sector: 4 turns + 2 measurements + deployment | ~40 s |
| 4 sectors total | ~460 s |
| Transit to center between sectors × 3 | ~30 s |
| Contingency (stalls, RECOVERY) | ~15 s |
| TOTAL | ~510 s ≈ 8.5 min (tight; test at Day 1) |
Fits within 8 min at 100mm/s. 6 strips per sector with 3×TCS34725 (100mm effective width). If over, cut to 5 strips and accept ~5% miss risk.
01 Mission
Navigate an octagonal 4-sector arena. In each sector locate 1 water pond and 2 soil patches at random positions. Water ponds are large: robot must sample from edge and reroute around them. Track position via encoder + IMU dead-reckoning. Maintain exclusion zones to avoid re-sampling. Operator can override at any time via mobile touch UI.
Sectors
4
Quadrant layout, ~90° each
Zones / Sector
3
1 water + 2 soil
Measurements
12
4 turbidity + 8 moisture
Speed
100mm/s
Fits 8-min limit
02 Arena Definition
| Parameter | Value |
| Shape | Regular octagon, ~1.5m diameter |
| Walls | Dark-stained timber, raised. Physical barrier. VL53L0X detects. |
| Layout | 4 equal quadrants divided by horizontal + vertical centerline |
| Sector boundaries | No physical dividers. Terrain color change only (TCS34725). |
| Zone positions | Random within each quadrant per run. Must sweep, not memorise. |
| Water zones | Recessed diamond-shaped slots (~45°), blue water, slightly reflective |
| Soil zones | Flat reddish-brown circular discs (~8 total, ~2 per sector) |
Terrain Types
| Quadrant | Terrain | Color | TCS34725 Dominant |
| Top-left | Sandpaper | Light beige, smooth matte | High R+G, low B |
| Top-right | Wet soil | Dark brown, reflective | Low all, slight R |
| Bottom-left | Grass | Muted green, uniform | G dominant |
| Bottom-right | Sand | Yellow-tan, ripple texture | High R+G |
Outer octagon wall (~1.5m diameter, dark timber)
╱─────────────────────────────────────────╲
╱ ╲
│ Top-left: Top-right: │
│ SANDPAPER WET SOIL │
│ (light beige) (dark brown) │
│─────────────────────────┼───────────────────│
│ Bottom-left: Bottom-right: │
│ GRASS SAND │
│ (muted green) (yellow-tan) │
╲ ╱
╲─────────────────────────────────────────╱
◆ = water zone (diamond slot, blue) ● = soil zone (reddish disc)
Zone positions are RANDOM each run — robot must sweep, not navigate to fixed coords.
Zone positions are random per run. No memorised coordinates. Full sector sweep required every time.
03 Hardware Rev 5
Rev 5 changes vs Rev 4: ESP32-S3 → ESP32 (regular, from kit). DRV8833 → L298N (from kit). Added MPU6050 IMU + Hall encoders. Single LM2596 (L298N takes 12V direct). Kill switch added.
Controller — ESP32-WROOM-32 (from kit)
Dual-core Xtensa LX6, 240MHz, 4MB flash, WiFi b/g/n, 30-pin. Runs navigation, dead-reckoning fusion, color classification, measurement, HTTP + WebSocket server concurrently.
Sensors
| Sensor | Qty | Bus | Role |
| TCS34725 | 3 | Wire0 via TCA9548A | L/C/R front array. Terrain ID, zone detection, boundary. |
| TCA9548A | 1 | Wire0 (0x70) | I2C mux. All 3 TCS34725 share address 0x29. |
| VL53L0X | 1 | Wire1 (separate bus) | Forward-facing. Outer wall and large obstacle detection. |
| MPU6050 | 1 | Wire0 (0x68) | IMU DMP yaw fusion. 60% IMU / 40% encoder heading blend. |
| Hall encoder | 2 | GPIO ISR | Left + right shaft encoders. 20 ppr, 65mm wheel → 10.2mm/tick |
| SEN0189 | 1 | ADC GPIO 1 | Turbidity sensor (NTU), seesaw arm water side |
| Cap. Moisture v2.0 | 1 | ADC GPIO 2 | Soil moisture (%), seesaw arm soil side |
VL53L0X address conflict resolved: Default 0x29 collides with TCS34725. Fix: VL53L0X on Wire1 (GPIO 21/22), TCS34725 on Wire0 (GPIO 8/9) via TCA9548A mux.
Actuation
| Component | Qty | Role |
| DC geared motor 12V | 2 | Differential drive |
| L298N motor driver (from kit) | 1 | 12V direct input. IN1–IN4 direction + ENA/ENB PWM speed |
| Servo MG996R (or SG90) | 1 | Seesaw sensor arm, powered from 5V rail |
| Kill switch (large latching) | 1 | Top of chassis. Cuts battery line for technical inspection. |
| NeoPixel WS2812B | 1 | Status LED, state feedback |
Power Architecture — Single Buck
8× AA batteries (12V nominal, ~10Ah, ~2h runtime estimate)
│
├──── KILL SWITCH (large latching, top of chassis)
│
├──── L298N MOTOR DRIVER (12V direct — no buck needed)
│ ├── Motor Left
│ └── Motor Right
│
├──── LM2596 Buck → 5.0V
│ ├── Servo (4.8–7.2V ✓)
│ ├── SEN0189 turbidity supply
│ └── L298N 5V logic pin
│
└──── AMS1117-3.3 (from 5V rail)
├── ESP32-WROOM-32
├── TCS34725 ×3
├── TCA9548A
├── VL53L0X (on Wire1)
├── MPU6050
└── Capacitive Moisture v2.0
Only 1 LM2596 needed. L298N accepts 12V direct. Rev 4 needed 2 bucks (DRV8833 required 9V). Saves board space and cost.
Physical Spec
| Parameter | Value |
| Chassis L × W × H | 200 × 160 × 250mm (arm vertical) |
| Weight estimate | ~850g (chassis + batteries + sensors) |
| Wheel diameter | 65mm |
| Wheelbase | 120mm center-to-center |
04 Why 3 TCS34725 Sensors
| Capability | 1 sensor | 3 sensors |
| Detect zone exists | ✓ | ✓ |
| Center arm accurately | ✗ | ✓ |
| Detect water from side approach | ✗ | ✓ |
| Distinguish zone vs sector boundary | ✗ | ✓ |
| 100mm effective strip width | ✗ | ✓ |
| Reliable wall detection at angle | ✗ | ✓ |
Arm centering logic
| L | C | R | Action |
| TERRAIN | ZONE | TERRAIN | Centered → deploy arm |
| ZONE | ZONE | TERRAIN | Drifted left → nudge right |
| TERRAIN | ZONE | ZONE | Drifted right → nudge left |
| ZONE | TERRAIN | TERRAIN | Zone to left → steer toward |
Strip coverage
Sensor spread: 3 sensors × 20mm spacing = 60mm
Effective strip width: 2 × zone_radius + spread = 100mm per pass
Sector depth: ~750mm → 750 / 100 = 7.5 → use 6 strips (overlap safety)
2.5× fewer strips than 1 sensor (40mm strip) → faster scan
05 Chassis Layout
FRONT
┌──────────────────────────────────────┐
│ [VL53L0X] ← wall detection │ ← Wire1 (GPIO 21/22)
│ [TCS-L] [TCS-C] [TCS-R] │ ← Wire0, 8mm above ground
│ │
│ ╔═════ SERVO ARM (seesaw) ════════╗ │
│ ║ [SEN0189] [Cap. Moisture] ║ │ ← pivots down from front
│ ╚══════════════════════════════════╝ │
│ │
│ ◉ Motor-L Motor-R ◉ │
│ [Hall Enc-L] [Hall Enc-R] │ ← 20 ppr, shaft-mounted
│ │
│ [ESP32] [L298N] [MPU6050] │
│ [8×AA pack 12V] [LM2596→5V] │
│ [KILL SW] │ ← top, accessible
└──────────────────────────────────────┘
REAR
Wheel axle: ≥80mm behind TCS34725 (water safety clearance)
TCS34725 spacing: 20mm apart, pointing straight down
06 Pin Assignment Rev 5
| GPIO | Function | Notes |
8 / 9 | Wire0 SDA / SCL | TCS34725 ×3 via TCA9548A (0x70) + MPU6050 (0x68) |
21 / 22 | Wire1 SDA / SCL | VL53L0X only. Separate bus avoids 0x29 address collision. |
1 | SEN0189 ADC | Voltage divider ×1.5 correction applied in firmware |
2 | Soil moisture ADC | 3.3V safe, direct |
10 | L298N IN1 (Motor-L) | Direction |
11 | L298N IN2 (Motor-L) | Direction |
12 | L298N IN3 (Motor-R) | Direction |
13 | L298N IN4 (Motor-R) | Direction |
14 | L298N ENA (Motor-L PWM) | analogWrite speed control |
15 | L298N ENB (Motor-R PWM) | analogWrite speed control |
16 | Servo PWM | 50Hz, 1–2ms pulse width |
17 | Hall encoder LEFT | ISR rising edge → enc_left++ |
18 | Hall encoder RIGHT | ISR rising edge → enc_right++ |
23 | NeoPixel WS2812B data | Single LED status indicator |
07 Dead-Reckoning 2.0 — Encoder + IMU Fusion
Rev 5 upgrade: Time-based DR (±60mm) replaced with Hall encoder odometry fused with MPU6050 DMP yaw. Target accuracy ±20mm, tight enough for 80mm exclusion zones.
Encoder constants
cpp#define WHEEL_DIAM_MM 65.0f
#define PULSES_PER_REV 20
#define MM_PER_TICK (3.14159f * WHEEL_DIAM_MM / PULSES_PER_REV) // ≈ 10.2mm
#define WHEEL_BASE_MM 120.0f
#define EXCL_RADIUS 80.0f // mm — tighter than Rev 4 (120mm) due to ±20mm accuracy
volatile int32_t enc_left = 0, enc_right = 0;
void IRAM_ATTR isr_left() { enc_left++; }
void IRAM_ATTR isr_right() { enc_right++; }
Fused position update (call every 20ms)
cppvoid update_pos_v2() {
noInterrupts();
int32_t dl_ticks = enc_left;
int32_t dr_ticks = enc_right;
enc_left = enc_right = 0;
interrupts();
float dl = dl_ticks * MM_PER_TICK;
float dr = dr_ticks * MM_PER_TICK;
float dc = (dl + dr) * 0.5f;
float dtheta_enc = (dr - dl) / WHEEL_BASE_MM;
float dtheta_imu = get_imu_delta_yaw(); // MPU6050 DMP
float dtheta = 0.6f * dtheta_imu + 0.4f * dtheta_enc;
pos.heading += dtheta;
pos.x += cosf(pos.heading) * dc;
pos.y += sinf(pos.heading) * dc;
} // origin (0,0) = arena center
Exclusion zones
cppbool already_sampled(float x, float y) {
for (int i = 0; i < sample_count; i++) {
float d = hypotf(x - past_samples[i].x, y - past_samples[i].y);
if (d < EXCL_RADIUS) return true;
}
return false;
}
Accuracy comparison
| Method | Accuracy | Cost |
| Time-based DR (Rev 4) | ±60mm | $0 |
| Encoder only | ±30mm | ~$6 AUD |
| Encoder + MPU6050 DMP REV 5 | ±20mm | ~$13 AUD |
| UWB (BU01/DW1000) | ±10mm | Not feasible (needs external anchors) |
08 Water Pond — Sampling and Rerouting
Pond is large. Robot cannot cross. Detect → stop → sample from edge → reroute around → resume sweep.
cpp#define TURN_90_MS 1885 // π/2 × WHEEL_BASE_MM / SPEED_MM_S × 1000
// = 1.5708 × 120 / 100 × 1000 = 1885ms
void on_water_detected() {
stop_motors();
if (already_sampled(pos.x, pos.y)) { state = POND_REROUTE; return; }
arm_water_down(); delay(1500);
float ntu = read_turbidity(); arm_neutral();
char val[16]; snprintf(val, 16, "%.1f NTU", ntu);
record_sample(current_sector, WATER, val);
state = POND_REROUTE;
}
void reroute_around_pond() {
reverse(100, 800);
turn_right(100, TURN_90_MS);
while (true) {
bool wR = in_water(sensors[RIGHT]);
bool wC = in_water(sensors[CENTER]);
if (wC) { turn_right(60, 200); }
else if (!wR) { forward(100, 800); break; }
else { forward(100, 100); }
update_pos_v2();
}
turn_left(100, TURN_90_MS);
state = SWEEP;
}
Current heading →
───────── [POND] ─────────────
↑
Water detected
│
Reverse 800ms (100mm/s)
│
Turn right 1885ms (90°)
│
Follow pond edge (R=water) ──────→
│
Turn left 1885ms
│
────────────────────────────────────→
Resume heading, state = SWEEP
09 Navigation — Sector Coverage
Strip math (Rev 5)
Drive speed: 100 mm/s (was 40mm/s in Rev 4 — FATAL time issue)
Strip width: 100 mm (3-sensor array)
Strips/sector: 6 (600mm coverage with overlap in 750mm depth)
Time/strip: 7.5s drive + 3.5s turns = 11s
4 sectors total: 4 × (6×11s + 2×5s sample) = 4 × 76s = 304s ≈ 5.1 min
INIT + transits: ~60s
Total estimate: ~364s ≈ 6.1 min ✓ margin: ~114s
Terrain color classification
cpp#define TERRAIN_THRESHOLD 0.25f
float color_distance(ColorReading a, ColorReading b) {
float tA = a.r+a.g+a.b+1, tB = b.r+b.g+b.b+1;
float rA=(float)a.r/tA, gA=(float)a.g/tA;
float rB=(float)b.r/tB, gB=(float)b.g/tB;
return sqrtf(powf(rA-rB,2)+powf(gA-gB,2));
}
bool in_current_terrain(ColorReading r) {
return color_distance(r, terrain_baseline) < TERRAIN_THRESHOLD;
}
// in_water(): b/(r+g+b+1) > 0.45f
// in_soil(): r/(r+g+b+1) > 0.40f (reddish disc)
Terrain-specific PWM
| Terrain | Risk | ENA/ENB PWM duty |
| Sandpaper | High friction → stall | 45% |
| Grass | High drag, uneven | 70% |
| Wet soil | Slipping | 50% |
| Sand | Wheel sinking | 40% |
Target 100mm/s on all terrains. Encoder feedback confirms actual speed; adjust PWM during Day 1 test.
10 State Machine
INIT
│ Wire0 + Wire1 init · WiFi AP · WebSocket :81 · SPIFFS mount
│ MPU6050 DMP · encoder ISRs attach · arm_neutral() · NeoPixel blue
│ auto-calibrate 5s · pos=(0,0,0) · sample_count=0 · sector=0
↓
SWEEP ← lawnmower in current sector
│ update_pos_v2() every 20ms · check_stall() every 20ms
│ TCS34725 poll every 50ms
│ at_outer_wall() → reverse + next strip
│ ANY sensor WATER → POND_SAMPLE (exclusion check first)
│ CENTER sensor SOIL → APPROACH_SOIL (exclusion check first)
│ All 6 strips done → terrain_complete() ?
│ yes → TRANSIT
│ no → STRIP_WIDTH /= 2, re-sweep
│ check_stall() → RECOVERY
↓
POND_SAMPLE
│ already_sampled? → POND_REROUTE
│ arm_water_down · read_turbidity · arm_neutral
│ record_sample(sector, WATER, "X.X NTU")
│ → POND_REROUTE
↓
POND_REROUTE
│ reverse 800ms · turn_right 1885ms · follow pond edge
│ cleared → turn_left 1885ms → SWEEP
↓
APPROACH_SOIL
│ forward ~100mm until arm pivot over disc
│ → MEASURE_SOIL
↓
MEASURE_SOIL
│ already_sampled? → SWEEP
│ arm_soil_down · read_moisture · arm_neutral
│ record_sample(sector, SOIL, "X.X%")
│ → SWEEP
↓
RECOVERY ← check_stall() fires (encoders idle >3s during SWEEP)
│ stop_motors · NeoPixel purple
│ pos.x=0; pos.y=0; pos.heading=get_imu_heading() (re-sync)
│ wait for encoder movement
│ → SWEEP (restart strip 1)
↓
TRANSIT
│ drive to center (0,0) · MPU6050 heading re-sync
│ sector++ · sector == 4 → DONE
↓
DONE
stop · NeoPixel green · write_results_json() · Serial.print all
Stall detection (RECOVERY trigger)
cppvoid check_stall() {
static int stall_ms = 0;
if (state == SWEEP && enc_left == 0 && enc_right == 0) {
stall_ms += 20;
if (stall_ms > 3000) { stall_ms = 0; state = RECOVERY; }
} else { stall_ms = 0; }
} // called every 20ms from main loop
11 Zone Coverage Guarantee
Coverage condition: STRIP_WIDTH ≤ 2×zone_radius + sensor_spread
Water slot est. width 50mm: 100 ≤ 50 + 60 = 110mm ✓
Soil disc est. radius 30mm: 100 ≤ 60 + 60 = 120mm ✓
Soil disc radius 15mm worst: 100 ≤ 30 + 60 = 90mm ✗ → halve STRIP_WIDTH
Re-sweep: if terrain_complete() false after all strips
→ STRIP_WIDTH = 50mm, repeat sector (adds ~40s per sector)
12 No-Duplicate Guarantee
cppbool terrain_complete(int sector) {
int water=0, soil=0;
for (int i=0; i<sample_count; i++) {
if (in_sector(past_samples[i].x, past_samples[i].y, sector)) {
if (past_samples[i].type==WATER) water++;
if (past_samples[i].type==SOIL) soil++;
}
}
return (water >= 1 && soil >= 2);
}
// already_sampled(x,y) checked before every arm deployment — EXCL_RADIUS=80mm
13 Measurement Code
Standardised record_sample
cpptypedef struct {
float x, y;
int sector;
enum { WATER, SOIL } type;
uint32_t timestamp_ms;
char value[16]; // "42.3 NTU" or "61.2%"
} Sample;
Sample past_samples[16];
int sample_count = 0;
void record_sample(int sector, int type, const char* value) {
Sample& s = past_samples[sample_count];
s.x = pos.x; s.y = pos.y;
s.sector = sector; s.type = type;
s.timestamp_ms = millis();
strncpy(s.value, value, sizeof(s.value)-1);
sample_count++;
}
Servo arm + read functions
cppvoid arm_neutral() { arm.write(90); delay(500); }
void arm_water_down() { arm.write(0); delay(600); } // CCW → water side
void arm_soil_down() { arm.write(180); delay(600); } // CW → soil side
float read_turbidity() {
float v = (analogRead(TURB_PIN)/4095.0f) * 3.3f * 1.5f;
return fmaxf(0, -1120.4f*v*v + 5742.3f*v - 4352.9f);
}
float read_moisture() {
int raw = analogRead(SOIL_PIN);
return fmaxf(0, fminf(100, (float)(SOIL_DRY-raw)/(SOIL_DRY-SOIL_WET)*100.0f));
}
void sample_water(int sector) {
arm_water_down(); delay(1500);
float ntu = read_turbidity(); arm_neutral();
char val[16]; snprintf(val, 16, "%.1f NTU", ntu);
record_sample(sector, WATER, val);
}
void sample_soil(int sector) {
arm_soil_down(); delay(1500);
float pct = read_moisture(); arm_neutral();
char val[16]; snprintf(val, 16, "%.1f%%", pct);
record_sample(sector, SOIL, val);
}
14 Data Storage — SPIFFS JSON
After each sample, results written to /results.json in SPIFFS. Served at 192.168.4.1/results. Also dumped to Serial on DONE. Uses ArduinoJson v6.
JSON output schema
json{
"run_id": "envirobot_001",
"total_samples": 12,
"duration_ms": 420000,
"samples": [
{
"id": 1,
"sector": 1,
"type": "water",
"value": "42.3 NTU",
"x_mm": 310.5,
"y_mm": -125.8,
"timestamp_ms": 34200
},
{
"id": 2,
"sector": 1,
"type": "soil",
"value": "61.2%",
"x_mm": 280.1,
"y_mm": -200.3,
"timestamp_ms": 67800
}
]
}
Write function (ArduinoJson v6)
cppvoid write_results_json() {
StaticJsonDocument<4096> doc;
doc["run_id"] = "envirobot_001";
doc["total_samples"] = sample_count;
doc["duration_ms"] = millis();
JsonArray arr = doc.createNestedArray("samples");
for (int i=0; i<sample_count; i++) {
JsonObject o = arr.createNestedObject();
o["id"] = i+1;
o["sector"] = past_samples[i].sector;
o["type"] = (past_samples[i].type==WATER) ? "water" : "soil";
o["value"] = past_samples[i].value;
o["x_mm"] = past_samples[i].x;
o["y_mm"] = past_samples[i].y;
o["timestamp_ms"] = past_samples[i].timestamp_ms;
}
File f = SPIFFS.open("/results.json", "w");
serializeJson(doc, f);
f.close();
}
HTTP endpoints
| Method | Path | Response |
| GET | / | Dashboard HTML |
| GET | /results | results.json download |
| GET | /status | Current state + pos + sample_count JSON |
| GET | /rc | Mobile remote control UI |
15 Auto-Calibration at Startup
Zero manual setup. Place robot at arena center, power on, wait 5 seconds for green LED.
1
0–3s: Read terrain floor 30× at center → average RGB → terrain_baseline. NeoPixel blue pulse.
2
3–4s: Seed water ref (b/total > 0.45) and soil ref (r/total > 0.40).
3
4–5s: Verify references are ≥3× TERRAIN_THRESHOLD apart. If not → blink red, pause, wait for re-seat.
4
5s: NeoPixel green → SWEEP begins. Re-calibrates terrain baseline at each sector transition.
| NeoPixel | State |
| 🔵 Blue slow pulse | INIT / calibrating. Do not move. |
| 🟢 Green | Ready / DONE |
| 🟡 Yellow | Zone detected, arm deploying |
| ⚪ White | Measuring |
| 🔴 Red | Wall / rerouting / stall |
| 🟣 Purple | RECOVERY state. Waiting to be replaced. |
| 🔵 Blue fast blink | MANUAL RC override active |
16 Communications — WiFi AP Mode
No internet required. ESP32 creates its own WiFi network. Phone/laptop connects directly. No router needed.
cppWiFi.softAP("EnviroBot", "enviro123");
// Connect phone to "EnviroBot" WiFi → open 192.168.4.1
// HTTP server: port 80 (pages + JSON)
// WebSocket: port 81 (RC commands + status push)
| Channel | Protocol | Port | Use |
| Web pages + data download | HTTP | 80 | Dashboard, /rc, /results |
| RC joystick commands | WebSocket | 81 | ~10ms latency joy packets |
| Status stream | WebSocket | 81 | State + pos push every 500ms |
17 Remote Control — Mobile Touch UI
Semi-autonomous hybrid. Robot runs autonomously by default. Operator can override at any moment. Robot still auto-deploys sensors even in MANUAL mode.
Mobile UI at 192.168.4.1/rc
┌─────────────────────────────────────────────┐
│ EnviroBot RC 🔋 12.1V ● 4/12 samples│
│─────────────────────────────────────────────│
│ │
│ ┌─────────────────────────────────────┐ │
│ │ 3D Arena mini-view (Three.js) │ │
│ │ robot dot + path + zone markers │ │
│ └─────────────────────────────────────┘ │
│ │
│ [AUTO ↔ MANUAL] Speed [──●────] │
│ │
│ ╭─────────────╮ │
│ │ │ │
│ │ nipple.js │ ← virtual │
│ │ Joystick │ joystick │
│ │ │ │
│ ╰─────────────╯ │
│ │
│ State: SWEEP Sector: 2/4 Pos: 310,−125 │
└─────────────────────────────────────────────┘
WebSocket message format
json// Phone → Robot (RC command):
{ "cmd": "joy", "x": 0.85, "y": 0.30 }
// x: left/right (−1 to +1), y: forward/back (−1 to +1)
{ "cmd": "auto" } // resume autonomous from current position
{ "cmd": "stop" } // immediate stop
{ "cmd": "arm_water" } // manual arm trigger
// Robot → Phone (status push every 500ms):
{ "state": "SWEEP", "sector": 2, "samples": 4, "x": 310.5, "y": -125.8 }
Joystick to motor PWM
cppvoid apply_joystick(float jx, float jy) {
float left = fmaxf(-1, fminf(1, jy + jx)); // tank-drive mix
float right = fmaxf(-1, fminf(1, jy - jx));
set_motors((int)(left*255), (int)(right*255));
}
Libraries needed: ESPAsyncWebServer + AsyncWebSocket (Arduino). nipple.js loaded from CDN in the /rc page. No app install on phone.
18 Data Visualization App — visualizer.html
Standalone single HTML file. Drag-drop results.json → renders 3D arena + charts. Open in any browser, no server needed. Scores Cat 2 (data output) + supports Cat 6 (case presentation).
Layout
┌──────────────────────────────────────────────────────────────┐
│ EnviroBot Data Visualizer [📂 Load JSON] │
│──────────────────────────────────────────────────────────────│
│ │ │
│ 3D ARENA VIEW │ TURBIDITY (NTU) — 4 water samples │
│ Three.js r165 │ S1 ██████░░ 42.3 NTU │
│ │ S2 ████████ 81.1 NTU ← above EPA │
│ ◆ water zones │ S3 ████░░░░ 21.0 NTU │
│ ● soil zones │ S4 ███████░ 63.4 NTU │
│ robot path line │ │
│ │ SOIL MOISTURE (%) — 8 samples │
│ [click zone] │ S1-A ████░░ 61% S1-B ██░░ 38% │
│ → popup: value, │ S2-A ████████ 82% S2-B ████░ 71% │
│ sector, time │ S3-A █░░░░░ 22% S3-B █░░░░ 19% │
│ │ S4-A ███████ 78% S4-B ████░ 52% │
│────────────────── │────────────────────────────────────────│
│ ──●───────────── │ TIMELINE: drag to replay robot path │
│ 0s 480s │ ◀ ● ▶ 02:15 Sample 6 of 12 │
└──────────────────────────────────────────────────────────────┘
Features
| Feature | Implementation |
| 3D arena | Three.js r165 from CDN. Octagon walls, 4 terrain quadrant planes with terrain colors |
| Zone markers | Water = blue diamond mesh (rotated plane). Soil = reddish-brown disc. Placed from x_mm/y_mm |
| Robot path | THREE.Line geometry connecting sample positions in timestamp order |
| Click-to-inspect | THREE.Raycaster on click → popup shows value, sector, terrain, timestamp |
| Turbidity bar chart | Chart.js 4 horizontal bars. Color-coded: green <4 NTU (safe), yellow 4-50, red >50 |
| Moisture bar chart | Chart.js 4. 8 bars grouped by sector. Color: blue-scale (dry=light, wet=dark) |
| Timeline scrubber | HTML range input. Drag → show robot position at that moment, highlight active sample |
| Drag-drop JSON | Drop results.json anywhere on page → parse → render all. Or click 📂 to browse file |
| Dark theme | Matches plan doc color system (--bg #0d1117, --blue #58a6ff) |
Three.js bootstrap
js// Three.js r165 from CDN, Chart.js 4 from CDN
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(45, w/h, 1, 5000);
camera.position.set(0, 900, 900);
camera.lookAt(0, 0, 0);
const terrainColors = [0xD2B48C, 0x4E342E, 0x558B2F, 0xF9A825];
// sandpaper, wet soil, grass, sand
function loadResults(json) {
json.samples.forEach(s => {
const mesh = s.type === 'water' ? makeWaterDiamond() : makeSoilDisc();
mesh.position.set(s.x_mm * 0.5, 2, s.y_mm * 0.5);
mesh.userData = s;
scene.add(mesh);
});
drawPath(json.samples);
buildCharts(json.samples);
}
Self-contained file. Share visualizer.html: anyone can open it, drop their results.json, and get the full analysis. No install, no backend.
19 Scoring Strategy
Cat 6 — Case Competition
40 pts
Cat 1 — Autonomous operation
30 pts
Cat 2 — Data output
10 pts
Cat 3 — Build quality
5 pts
Score-per-effort map
| Category | Pts | What locks it in | Risk |
| Cat 6 (Case Comp) | 40 | Strong narrative + visualizer + policy recs | LOW (fully in our control) |
| Cat 1 (Autonomous) | 30 | Complete all 12 samples in 8 min | MED (test on Day 1) |
| Cat 2 (Data) | 10 | WiFi JSON + visualizer.html live demo | LOW (software deliverable) |
| Cat 4 — Innovation | 10 | Pitch hybrid RC + DR 2.0 + viz app explicitly | LOW |
| Cat 3 — Build | 5 | Clean harness, hot-glue or zip-tie all wires | LOW |
| Cat 5 — Aesthetics | 5 | NeoPixel LEDs + printed chassis panels | LOW |
Day 1 Pitch (3 min)
- [0:00–0:30] Problem: environmental monitoring in inaccessible terrain. Manual = slow, expensive, dangerous.
- [0:30–1:00] Solution: EnviroBot. Autonomous survey, dead-reckoning 2.0, 12 samples in 8 min.
- [1:00–1:45] Live demo: power on → calibrate → first sector sweep → sample logged on phone UI.
- [1:45–2:15] Innovation: hybrid RC, encoder+IMU fusion, mobile touch UI, single-file data viz.
- [2:15–2:45] Data story: open visualizer.html → 3D arena → explain what the readings mean.
- [2:45–3:00] Call to action: fleet deployment = early flood/contamination detection at 5% of current cost.
20 Day 2 — Closing Night Case Competition
Cat 6 = 40 pts. Worth more than Cat 2+3+4+5 combined. Invest real prep time here.
1-Hour Day 2 Prep Plan
1
[0–15 min] Full arena run. Download results.json. Open visualizer.html. Screenshot 3D + charts.
2
[15–30 min] Write argument. Use real data: find the most interesting finding (highest NTU, driest soil). Build 3-point policy argument below.
3
[30–45 min] Rehearse. Present to each other, time it. Aim 5–7 min. Cut what's over.
4
[45–60 min] Backup prep. If robot breaks: use pre-run data + visualizer screenshots. HDMI adapter in bag. Laptop charged.
Case Argument Template
| Section | Content (fill with real data) | Time |
| Hook | "In 8 minutes, our robot found contamination in 2 of 4 water zones that manual inspection would have missed." | 30s |
| Data | Show visualizer 3D arena. Highlight highest-NTU zone. "Sector X turbidity = Y NTU, above EPA threshold of 4 NTU." | 90s |
| Scale | "10 robots cover a 100m flood zone in under an hour. Manual = 2 days, $5000. Robot fleet = 4 hours, $200." | 60s |
| Policy | 1) Deploy robot fleets post-flood events. 2) Integrate with council water alert systems. 3) Open-source firmware for NGO deployment. | 90s |
| Close | "The data is here. The robot is here. The only question is who deploys it first." | 30s |
Visualizer as closing prop. End with 3D arena on screen, all 12 sample points visible. Invite judges to click zones if time allows.
21 Bill of Materials
From Kit (no cost)
| Item | Notes |
| ESP32-WROOM-32 | Main controller |
| L298N motor driver | Dual H-bridge, 12V direct |
| DC geared motors ×2 | 12V |
| Servo motor (MG996R or SG90) | |
| 8× AA battery holder | 12V nominal |
| LM2596 buck module | Set to 5V output |
| AMS1117-3.3 LDO | 3.3V from 5V rail |
| Breadboard + jumper wires | |
Additional Purchases
| Item | Qty | Est. AUD |
| TCS34725 color sensor | 3 | $12 |
| TCA9548A I2C mux | 1 | $4 |
| VL53L0X distance sensor | 1 | $5 |
| MPU6050 IMU module | 1 | $4 |
| Hall encoder wheel kit (pair) | 1 | $8 |
| SEN0189 turbidity sensor | 1 | $10 |
| Capacitive soil moisture v2.0 | 1 | $3 |
| Large latching kill switch | 1 | $4 |
| NeoPixel WS2812B | 1 | $3 |
| 8× AA alkaline batteries | 2 packs | $6 |
| TOTAL ADDITIONAL | ~$59 AUD |
~$59 AUD
within $50–150 AUD budget
TCS34725 + MPU6050: AliExpress/eBay (~1-week lead time). SEN0189, hall encoders: Core Electronics or Amazon AU (next-day). Jaycar for kill switch.
22 Pre-Competition Checklist
Technical Inspection
- Kill switch accessible: top of chassis, latches off, clearly visible to officials
- No loose wires: all soldered or firm dupont, zip-tied to chassis
- Fresh batteries: 8× AA alkaline, measured ≥11.8V under load
- Robot within size limits: measure L/W/H, confirm rulebook compliance
- Servo travel verified: 0°/90°/180° all reach correctly, no mechanical bind
Day 1 Morning (Test Day)
- Power on at center → NeoPixel blue pulse during 5s calibration
- NeoPixel green at 5s → SWEEP starts → robot moves straight?
- Serial Monitor: encoders ticking? Position updating?
- Zone detection test: TCS over water slot → arm deploys water side
- Zone detection test: TCS over soil disc → arm deploys soil side
- Full 8-min arena run: confirm 12 samples collected
- Connect phone to EnviroBot WiFi → open 192.168.4.1/results → JSON visible
- Open visualizer.html → drop results.json → 3D + charts render correctly
- Test /rc page on phone → joystick controls robot in MANUAL mode
- MANUAL → AUTO toggle → robot resumes autonomous sweep
- Kill switch test: hit mid-run → robot stops immediately
Day 2 Morning
- Final full arena run → collect best results.json
- Open visualizer.html with real data → screenshot for case slides
- Rehearse 5–7 min case presentation (argument from §20)
- Laptop charged + HDMI adapter packed
- visualizer.html open with results.json loaded on laptop
- Fresh batteries installed ≥11.8V
- results.json backed up to phone + USB stick