Runtime Config + Calibration Wizards
Implemented. Decided 2026-07-20 via grilling, shipped ahead of Testing Day (Thu 30 July). Design history in
arduino/CONFIG_PLAN.md; firmware lives inrobot_config.h+config_store.cpp/.h, routes inwifi_server.cpp, UI intest_page.h.
Goal: change tuning values without recompiling. Nothing more.
Scope cut: no cloud config
A cloud-backed config store was considered and dropped. RobotAP plus the test page already delivers the goal — there's no run-time need to reach the internet just to change a number. configFetchUrl was deleted from the design. uploadUrl was deleted too — AP-only means the robot has no internet path at all, so there is nothing for an upload URL to point at. Both /api/viz-runs routes have been deleted from the webapp.
Architecture
boot
├─ RobotConfiguration config; // compiled-in defaults == today's #define values
├─ SPIFFS.begin() // already happens
└─ loadConfig() // merge /config.json over defaults, clamp, apply
// missing file or bad JSON → defaults, log warning, continue
test page (192.168.4.1/test over RobotAP)
├─ GET /config → { values..., runActive: bool }
├─ POST /config → validate → clamp → apply RAM → write /config.json [409 if runActive]
├─ POST /config/reset → delete /config.json → revert to defaults [409 if runActive]
└─ POST /cal/capture → { target } → run-path capture → { raw } [409 if runActive]
Single source of truth: ESP32 SPIFFS. No network dependency at boot. No cloud.
| Method | Endpoint | Use |
|---|---|---|
GET |
/config |
Current values + runActive flag |
POST |
/config |
Validate → clamp → apply to RAM → write /config.json — 409 if a run is active |
POST |
/config/reset |
Delete /config.json, revert to compiled-in defaults — 409 if a run is active |
POST |
/cal/capture |
{ "target": ... } → run-path capture → { "raw": ... } — 409 if a run is active |
These land on the existing diagnostics page (192.168.4.1/test) documented in RC & Communications, alongside the sensor and motion self-tests.
Header split
config.h splits three ways:
hardware_pins.h (new — all #define, never runtime)
Pins, plus values that describe physical hardware: PIN_*, ULTRASONIC_TIMEOUT_US, TURBIDITY_DIVIDER_RATIO. The divider ratio is set by physical resistors — changing it at runtime cannot change the hardware.
robot_config.h (new — the 14 runtime fields)
#pragma once
#include <Arduino.h>
struct RobotConfiguration {
// ── Calibration outputs (wizard-owned) ──────────────────────────
int soilDryVal = 2850;
int soilWetVal = 1200;
int turbidityZeroRaw = 0; // clear-water ADC baseline, 0 = uncalibrated
float mmPerSecAtDrive = 150.0f;
float wheelBaseMm = 150.0f;
// ── Tunable by hand ─────────────────────────────────────────────
int driveSpeed = 255;
bool collisionGuardOn = true;
int obstacleStopMm = 120;
int turnSpeed = 255;
uint32_t sampleSettleMs = 2000;
uint32_t sampleWindowMs = 3000;
int servoNeutral = 90;
int servoWater = 0;
int servoSoil = 180;
};
extern RobotConfiguration config;
Stays #define in config.h
Ten constants, and only these ten: WIFI_SSID, WIFI_PASS, WIFI_AP_CHANNEL, POS_UPDATE_MS, BRAKE_MS, SAMPLE_TICK_MS, SENSOR_SAMPLES, OBSTACLE_PING_MS, OBSTACLE_TRIP_HITS, OBSTACLE_HYST_MM.
Everything the old list named is gone from the firmware entirely: UPLOAD_URL, RUN_TIME_LIMIT_MS, SECTOR_MAP, ENABLE_AUTONOMOUS, SAMPLE_LOCKOUT_MS, POND_BACKOFF_MS, SAMPLE_CREEP_*, SWEEP_LEG_TIMEOUT_MS, SAMPLE_TIME_BUDGET_MS, and the WiFi STA credentials.
The 14 editable fields
Every field in RobotConfiguration is hand-editable in the config editor. "Wizard-owned" just means the value comes from a physical measurement a wizard captures for you — soak a probe, drive a measured metre, park over a zone — instead of you deriving a number by hand. It's still a plain editable field; the wizard just fills it in for you and gets a Calibrate button.
Wizard-owned (5)
| Field | Default | Range | Captured by |
|---|---|---|---|
soilDryVal |
2850 | 0–4095 | Soil wizard |
soilWetVal |
1200 | 0–4095 | Soil wizard |
turbidityZeroRaw |
0 (uncalibrated) | 0–4095 | Turbidity wizard |
mmPerSecAtDrive |
150.0 | 10–1000 | Motion wizard |
wheelBaseMm |
150.0 | 50–500 | Motion wizard |
Hand-tunable (9)
| Field | Default | Range |
|---|---|---|
driveSpeed |
255 | 0–255 |
turnSpeed |
255 | 0–255 |
collisionGuardOn |
true |
bool |
obstacleStopMm |
120 | 30–2000 |
sampleSettleMs |
2000 | 200–10000 |
sampleWindowMs |
3000 | 500–10000 |
servoNeutral |
90 | 0–180 |
servoWater |
0 | 0–180 |
servoSoil |
180 | 0–180 |
collisionGuardOn is on by default — it is a safety feature — but it is deliberately optional. With the guard off, distance is still measured and reported on /pos; only the motor cut and the FWD refusal stop. Switch it off for tight manoeuvres against a wall, or when sampling right up against an edge.
obstacleStopMm must be calibrated against the real arena wall plus the robot's stopping distance at driveSpeed — measured, not guessed.
Clamping (mandatory — trust boundary)
Applied on every load and every POST, before the value reaches RAM:
| Field | Clamp | Reason |
|---|---|---|
servoNeutral/Water/Soil |
0–180 | servo stall / gear damage |
driveSpeed, turnSpeed |
0–255 | PWM range |
soilDryVal, soilWetVal |
0–4095 | 12-bit ADC |
soilDryVal != soilWetVal |
reject POST | div-by-zero in moisture map |
turbidityZeroRaw |
0–4095 | ADC |
mmPerSecAtDrive |
10–1000 | absurd values wreck path[] |
wheelBaseMm |
50–500 | ditto |
obstacleStopMm |
30–2000 | below 30 the robot hits the obstacle |
sampleSettleMs |
200–10000 | 8-min budget |
sampleWindowMs |
500–10000 | ditto |
- Out-of-range on POST →
400with the offending field named. - Out-of-range on load from file → clamp silently, log to serial.
Persistence
/config.json on SPIFFS, ArduinoJson (both already in the project).
- Load: struct constructed with defaults →
deserializeJson→ for each key present, overwrite. Missing keys keep defaults. No version field, no migration. - Save: serialize all 14 fields → write. One file write per Save click.
- Reset:
SPIFFS.remove("/config.json")→ re-init struct → apply.
Calibration wizards
Below the config editor, four wizards. Each is a modal: numbered steps, one instruction per step, a big Ready/Capture button, a 5s progress bar during capture, and the captured value shown before advancing. Cancel at any step discards.
Soil (hand-held, servo stays 90°)
- "Hold the soil probe in open air, completely dry. Wipe it if damp." → Capture →
soilDryVal - "Submerge the probe in water up to the marked line only — never past the electronics." → Capture →
soilWetVal - Show both values plus the computed span. Reject if span < 200 counts ("probe may be faulty or not actually wet/dry").
Turbidity (hand-held, one point)
- "Submerge the SEN0189 probe in clear tap water. No bubbles on the optical window." → Capture →
turbidityZeroRaw - Slope stays the SEN0189 datasheet curve — NTU =
datasheet_curve(raw)offset so clear water reads 0.
Motion (uses existing CAL_FWD / CAL_SPIN)
- "Place robot on the arena surface with 2m clear ahead. Mark the starting point." → Run (
CAL_FWD3000ms) - "Measure the distance travelled." → user types mm →
mmPerSecAtDrive = mm / 3.0 - "Mark the robot's heading. Robot will spin." → Run (
CAL_SPIN2000ms) - "How many degrees did it actually turn?" → user types deg → solve
wheelBaseMmfrom commanded vs observed - Offer "Spin again to verify" — repeat until estimated heading returns to start.
Collision distance (uses /cal/capture target obstacle)
- "Park the robot at the distance it should stop from — measure the real arena wall, then add the robot's stopping distance at
driveSpeed." → Capture - The capture takes one live ping, deliberately with no settle and no median (see
wifi_server.cpp) — the HC-SR04 answers in milliseconds, so the settle → window path a real sample uses would only add dead time. - Read the number into
obstacleStopMmand Save.
Capture endpoint
POST /cal/capture with {"target":"soil_dry"|"soil_wet"|"turb_zero"|"obstacle"}. The three sensor targets run the same settle → window → median path a real sample uses (sampleSettleMs → sampleWindowMs at SAMPLE_TICK_MS, SENSOR_SAMPLES ADC averages per tick) and return the raw pre-scaling value. obstacle is the exception: it bypasses settle and median entirely and returns a single ping. It does not write config — the page collects captures and sends them in the normal Save.
Calibration endpoints must be measured by the function that measures the run, or every reading carries a systematic offset.
Config editor
14 fields, grouped Calibration / Tuning, each showing current value, unit, and allowed range. Wizard-owned fields are editable too — the wizards just fill them in.
- Edits are local to the page. Nothing applies until Save.
- Dirty fields are highlighted; the Save button is enabled only when something is dirty.
- Save →
POST /config→ on success, toast + clear dirty state. - Restore defaults → confirm dialog →
POST /config/reset. Resulting values must match theRobotConfigurationstruct defaults inrobot_config.hexactly — that is where defaults now live.
Run lockout
GET /config returns runActive. The test page polls it with the existing 2Hz /pos poll. When true: all inputs disabled, wizards disabled, red banner "Run in progress — config locked until END_RUN". The server enforces this independently with 409 — the client is never trusted alone.
Needs bool isRunActive() exported from data_logger — the flag startRun() already implies.
Verification checklist
- Boot with no
/config.json→ defaults load, serial logs "no config, using defaults" - Save a value → power-cycle → value survives
- Hand-write invalid JSON to
/config.json→ boot uses defaults, does not hang - POST
servoSoil: 400→ 400 response, servo never moves - POST
soilDryVal == soilWetVal→ 400, no div-by-zero - Start a run → config editor locks, POST returns 409
- Restore defaults → values match the
robot_config.hstruct defaults exactly - Full soil wizard → resulting
soilDryVal/soilWetValmatch a manual serial read - Motion wizard → drive 1m, verify
path[]endpoint is within ~10% - Serial
[SAMPLE]lines unchanged in format (live_plot.pydepends on it)
This feature replaces the reflash loop in Calibration for these 14 constants. The manual bench procedures there — soil endpoints, turbidity curve fitting, collision distance, drive/wheel-base timing — remain the source of truth for how to measure each value; the wizards just capture the same measurement without a recompile.