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 in robot_config.h + config_store.cpp/.h, routes in wifi_server.cpp, UI in test_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 POST400 with 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°)

  1. "Hold the soil probe in open air, completely dry. Wipe it if damp." → Capture → soilDryVal
  2. "Submerge the probe in water up to the marked line only — never past the electronics." → Capture → soilWetVal
  3. 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)

  1. "Submerge the SEN0189 probe in clear tap water. No bubbles on the optical window." → Capture → turbidityZeroRaw
  2. Slope stays the SEN0189 datasheet curve — NTU = datasheet_curve(raw) offset so clear water reads 0.

Motion (uses existing CAL_FWD / CAL_SPIN)

  1. "Place robot on the arena surface with 2m clear ahead. Mark the starting point." → Run (CAL_FWD 3000ms)
  2. "Measure the distance travelled." → user types mm → mmPerSecAtDrive = mm / 3.0
  3. "Mark the robot's heading. Robot will spin." → Run (CAL_SPIN 2000ms)
  4. "How many degrees did it actually turn?" → user types deg → solve wheelBaseMm from commanded vs observed
  5. Offer "Spin again to verify" — repeat until estimated heading returns to start.

Collision distance (uses /cal/capture target obstacle)

  1. "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
  2. 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.
  3. Read the number into obstacleStopMm and 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 (sampleSettleMssampleWindowMs 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.
  • SavePOST /config → on success, toast + clear dirty state.
  • Restore defaults → confirm dialog → POST /config/reset. Resulting values must match the RobotConfiguration struct defaults in robot_config.h exactly — 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.h struct defaults exactly
  • Full soil wizard → resulting soilDryVal/soilWetVal match a manual serial read
  • Motion wizard → drive 1m, verify path[] endpoint is within ~10%
  • Serial [SAMPLE] lines unchanged in format (live_plot.py depends 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.