RC & Communications
WiFi — AP-only (the robot is the network)
At power-on the robot brings up its own access point. Reach it at http://192.168.4.1 or http://envirobot.local. There is no router, no hotspot, no internet path — wifi_server.cpp calls WiFi.mode(WIFI_AP) unconditionally, so there is no STA mode and no fallback to fail over to.
| Setting | Value |
|---|---|
| SSID | RobotAP |
| Password | enviro123 |
| Channel | 1 |
| Address | 192.168.4.1 (envirobot.local) |
Credentials are compile-time in config.h and printed on the robot for judges — nothing to provision at the venue, nothing to fail if the venue WiFi is congested.
HTTP Endpoints
| Method | Endpoint | Use |
|---|---|---|
GET |
/ |
RC page (served from flash — PROGMEM, no SPIFFS upload needed) |
POST |
/rc |
All commands (JSON body) |
GET |
/data |
Full run JSON (live; frozen after End Run; falls back to saved results.json after reboot) |
GET |
/samples |
Samples-only feed for live_plot.py — no 48KB path array, polled ~2s |
GET |
/pos |
{"x","y","h","t","d","blocked","guard"} — dead-reckoned pose, run seconds, last ping mm, forward-drive-blocked flag, guard-enabled flag. RC page polls at 2Hz (doubles as heartbeat) |
GET |
/ping |
204 heartbeat |
GET |
/test |
Diagnostics page (PROGMEM, same treatment as the RC page) |
POST |
/selftest |
Run one hardware self-test (JSON body) |
GET |
/view |
Live judge-facing data page — arena + position + bars + log (PROGMEM; polls /pos, /samples, /data) |
GET |
/config |
Current runtime config as JSON (see Runtime Config) |
POST |
/config |
Merge + validate + persist config to SPIFFS /config.json — 409 during a live run |
POST |
/config/reset |
Delete /config.json, revert to compiled-in defaults — 409 during a live run |
POST |
/cal/capture |
One calibration capture (soil_dry, soil_wet, turb_zero, obstacle) — returns {"raw"}, writes nothing |
There is no WebSocket and no UDP — plain HTTP is fast enough at D-pad cadence and far simpler to debug.
Command Protocol (POST /rc)
{ "cmd": "FWD" } // drive; optional "speed": 0-255 (default DRIVE_SPEED)
{ "cmd": "BWD" }
{ "cmd": "LEFT" } // turns always run at TURN_SPEED
{ "cmd": "RIGHT" }
{ "cmd": "STOP" }
{ "cmd": "SAMPLE_WATER", "sector": 2 } // arm 0° → settle 2s → 3s median → 90°; returns {"ntu": 42.3}
{ "cmd": "SAMPLE_SOIL", "sector": 2 } // arm 180° → same; returns {"pct": 68.5}
{ "cmd": "CAL_FWD", "ms": 2000 } // timed drive at DRIVE_SPEED, auto-stop (calibration)
{ "cmd": "CAL_SPIN", "ms": 4000 } // timed in-place spin at TURN_SPEED, auto-stop
{ "cmd": "REZERO", "h": 90 } // re-zero pose at eyeballed heading (officials re-centred the robot)
{ "cmd": "END_RUN" } // freeze run clock + save results.json to SPIFFS (nothing is uploaded)
sector(1–4) is chosen by the operator on the RC page — this is how samples get accurate sector + terrain labels.- Run clock: the first drive/sample after boot (or after END_RUN) starts it; CAL_*, STOP and REZERO don't.
- Every sample prints to serial as
[SAMPLE] S<n> <TYPE> <value>— parsed bylive_plot.py; never change the shape. - Any other
cmd(including the oldSTART) falls through to400 {"error":"unknown cmd"}. - Collision guard: while blocked,
FWDandCAL_FWDreturn 409{"error":"obstacle","mm":N}; BWD / LEFT / RIGHT / STOP always pass, so the robot can always reverse out of its own guard.
Failsafes
| Mechanism | Behaviour |
|---|---|
| Dead-man | RC page repeats the held drive command every 120ms; 500ms without one (WiFi drop, phone sleep) → motors stop |
| Active brake | driveBrake() shorts both motor terminals for BRAKE_MS (80ms) then de-energises — fires on STOP, on the 500ms dead-man timeout, and on the collision trip edge, so a WiFi drop halts the robot instead of letting it coast |
| Calibration timer | CAL_FWD/CAL_SPIN auto-stop at the requested ms (bounded 200–10000), exempt from the dead-man |
| Sample interlock | Sample commands stop the motors first — the servo never moves while driving |
RC Page Interface
Single self-contained HTML page compiled into the firmware (rc_page.h, generated from arduino/rc_page/index.html), served at 192.168.4.1 (or envirobot.local). No CDN, no external requests.
| Element | Function |
|---|---|
| Posbar | Live x/y/h from /pos + ⏱ run clock (amber 7:00, red 8:00) + the live collision-guard distance (#obst — red when blocked, amber "⚠ guard off" when guard is false). No IP is displayed |
| D-pad (hold-to-drive) | FWD / BWD / LEFT / RIGHT; STOP centre; release = active brake (stops dead, no coast — BRAKE_MS pulse then de-energise) |
| Sector selector S1–S4 | Tags the next sample with the sector the robot is in |
| 💧 Sample Water | Deploys arm A, logs NTU, shows reading in the log |
| 🌱 Sample Soil | Deploys arm B, logs %, shows reading |
| 🏁 End Run | Freezes the clock, persists results.json to SPIFFS |
| ⌖ Re-zero (compact) | Re-zeroes dead-reckoned pose at the eyeballed heading — for mid-run official re-centres |
| ⬇ Download JSON | Saves the full run /data to a file (native browser download, nothing is uploaded) |
| Header links | 📊 Data (/view) · 🔧 Diag (/test) — only these two |
| Status + log | Connection state; last 40 events with timestamps. "offline" is suppressed during the ~6s blocking sample |
(The timed drive/spin calibration moved to the Diagnostics page's Motion wizard — see below.)
Editing any page: change arduino/{rc_page,test_page,data_page}/index.html, run node arduino/tools/gen-pages.cjs to regenerate the PROGMEM headers, then re-flash.
Partition scheme: with all three pages (RC, Diag, Data) compiled in, the default 1.3MB app partition sits at ~98% (approximate — re-measure at the next build). Use Tools → Partition Scheme → "Huge APP (3MB No OTA/1MB SPIFFS)" — the app limit jumps to 3MB with ample SPIFFS (~50KB used). Switching the scheme erases SPIFFS on the next flash, so never do it between a run and its
/datadownload.
Diagnostics Page (192.168.4.1/test)
Second self-contained page (test_page.h, from arduino/test_page/index.html), reached from the 🔧 Diag link in the RC page header. It ports the standalone arduino/tests/*.ino bench sketches onto the running firmware, so every hardware check runs from the phone with no re-flashing to swap test sketches. It also hosts the calibration wizards (soil / turbidity / motion — the motion wizard replaces the old RC calibration panel and writes straight to the runtime config) and a POSE → ⌖ Re-zero pose control (a compact re-zero also stays on the RC page for mid-run use).
Sensor tests (read-only, safe)
| Test | Calls | Passes when |
|---|---|---|
| Turbidity | readTurbidityNTU() |
0–4000 NTU |
| Soil moisture | readSoilMoisturePct() |
0–100% |
| Obstacle distance | readObstacleDistanceMM() |
> 0 (echo returned; 0 = HC-SR04 timeout) |
The turbidity result reports raw ADC + volts + the active turbidityZeroRaw alongside the NTU ("42.3 NTU (raw 2810, 3.31 V, zero 0)") — the curve clamps to 0 both in clear water and on a dead sensor, so the volts are what tell a clamped-0 reading apart from broken wiring. The obstacle test id is obstacle; it reports "<mm> mm (stop at <obstacleStopMm>)" or "no echo".
▶ Run all sensor tests runs the three in sequence and reports a pass count. Motion tests are never included in Run All.
Motion tests (gated)
Motor drive and servo sweep physically move the robot, so both sit behind the ⚠ ARM toggle ("robot on blocks, arm path clear"). Without "armed": true the endpoint returns 409.
| Test | Sequence |
|---|---|
| Motor drive | fwd → bwd → left → right, 500ms each, auto-stop between |
| Servo arm sweep | water (0°) → neutral → soil (180°) → neutral |
Both are bounded and blocking and always end stopped / at neutral, so the dead-man is not involved.
Protocol
POST /selftest
{ "test": "turbidity" } // → {"test":"turbidity","ok":true,"value":"42.3 NTU"}
{ "test": "motors", "armed": true } // → 409 without armed
Valid test values: turbidity · soil · obstacle · motors · servo.
Guards: /selftest returns 409 during a live run (runLive() — End Run first, a test would burn run clock and pollute samples). That is the only guard. Every result also prints to serial as [TEST] <name>: <value> (PASS|FAIL).
Live Data Page (/view)
Third self-contained page (data_page.h, from arduino/data_page/index.html), reached from the 📊 Data link. Built to show a judge the run in real time — open it on a laptop/projector while the phone drives. It adds no firmware endpoints; it polls the existing feeds:
| Panel | Source | Update |
|---|---|---|
| Arena + live robot + heading + path trail + sample pins | /pos (seeded from /data) |
2 Hz |
| Water NTU / Soil % bar charts (hand-drawn canvas) | /samples |
~2 s |
| Sample table + event log | /samples |
~2 s |
| Header: run clock (amber 7:00 / red 8:00), n/12, sectors, status | /pos + /samples |
live |
The arena is the same square-arena renderer as the /viz visualiser, ported to vanilla canvas (no chart lib, no CDN — fully self-contained). A failed poll keeps the last state, so the ~6 s blocking sample just pauses the display. Laptop /view and phone RC run at the same time on one LAN.
§4.6 note: the WiFi data page is an "alternative method" needing prior written approval.
live_plot.py(matplotlib) remains the always-legal judged output;/viewis the polished secondary display.
Data Retrieval After a Run
- Stay joined to
RobotAP. - RC page ⬇ Download JSON, or
GET 192.168.4.1/data→ save JSON (survives reboot via SPIFFS fallback). - Keep
samples.csvfrom thelive_plot.pysession — second approved format. - Back up to two devices, then drag-drop into the visualiser for the judges.