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 by live_plot.py; never change the shape.
  • Any other cmd (including the old START) falls through to 400 {"error":"unknown cmd"}.
  • Collision guard: while blocked, FWD and CAL_FWD return 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 /data download.


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; /view is the polished secondary display.


Data Retrieval After a Run

  1. Stay joined to RobotAP.
  2. RC page ⬇ Download JSON, or GET 192.168.4.1/data → save JSON (survives reboot via SPIFFS fallback).
  3. Keep samples.csv from the live_plot.py session — second approved format.
  4. Back up to two devices, then drag-drop into the visualiser for the judges.