Working checklist for porting AutoLee from Arduino to native ESP‑IDF, directly off main
(the PlatformIO/pioarduino intermediate step, PR #3, was researched but never merged — see ADR
0001's status note). Derived from ADR 0001 — read it
first for the why and the per‑subsystem analysis.
- Base: started from v1.8 source (branch
feat/esp-idf, offmain). Karl's upstream release (v1.10.0) has since shipped and been diffed in - see Phase 7 - so this port now incorporates both v1.8 and the v1.10.0 delta.lib/autolee_logic/,test/,api/, and the governance tooling (clang-format, commitlint, pre-commit, yamllint) were carried forward from the PR #3 research since they're framework-agnostic; everything Arduino/PlatformIO-specific was dropped. - Framework: ESP‑IDF · Build tool: native
idf.py+ CMake (no PlatformIO). Target:esp32c6. - Model guidance: plan + the two hard/safety‑critical bindings (display, StallGuard) and final review with Opus; the mechanical bulk with Sonnet.
Legend: [ ] todo · [x] done · ⚙️ = requires hardware (bench‑verify with Karl) ·
DoD = definition of done. Do phases in order; each phase should build/test green before the next.
- Install ESP‑IDF ≥ 6.0, set up
export.sh. (Version pinned inmain/idf_component.ymlmatches what this port was built/tested against - not tied to FastAccelStepper, which was dropped; see Phase 4 and ADR 0001.) Originally ≥ 5.3; moved to 6.0.2 after the port, which is now the floor - see Phase 9. -
idf.py set-target esp32c6. - Confirm the VS Code ESP‑IDF extension works for Karl (Linux + Mac) — replaces the PlatformIO one.
- DoD:
idf.py --version+ target set; a hello‑world builds.
- Top‑level
CMakeLists.txt,main/component (CMakeLists.txt,app_main.c),sdkconfig.defaults. -
partitions.csv:nvs,otadata,ota_0/ota_1(~1.9 MB each),coredump— dual app slots enable OTA + rollback on 4 MB flash. Nostorage/SPIFFS partition: nothing in the firmware mounts a filesystem, so that space went to coredump instead (see CLAUDE.md). -
sdkconfig.defaults:esp32c6, 4 MB / dio, size‑opt (-Os), log level, and the safety knobs (Phase 6): OTA rollback, task+interrupt WDT, core dump to flash, brownout. - Minimal
app_main: NVS init, serial banner (git-derivedesp_app_get_description()->version), boot. - DoD:
idf.py buildclean; produces the app + a merged image (idf.py merge-bin). ⚙️ optional boot‑on‑serial check.
- Bring
lib/autolee_logic/in as an ESP‑IDF component (or shared include dir). - Port the Unity suites (
test/sources) to ESP‑IDF host testing (linux target / CMake+CTest). - Wire coverage (gcovr) for the host build.
- DoD: all host tests green (parity with the 39 today); coverage reported.
- Touch driver: the registry options (e.g.
mydazy/esp_lcd_touch_axs5106l) turned out to hard-require LVGL 9'slv_indev_create(), incompatible with this project's LVGL 8.4 pin. Portedthird_party/esp_lcd_touch_axs5106l/'s I2C protocol directly instead, onto the newi2c_master+gpioISR APIs (main/axs5106l_touch.{h,cpp}), registered as a plain LVGL 8lv_indev_drv_t(main/display_touch.cpp). -
esp_lcdpanel init on the shared SPI bus (main/display_touch.cpp). Turned out the panel is a JD9853 (ST7789-command-compatible but needing its own vendor init, not the plain ST7789), discovered by findingsrc/ui_touch.cpp's uncalled-outlcd_reg_init()/init_operationsbyte stream. Ported that sequence byte-for-byte viaesp_lcd_panel_io_tx_param()instead of usingesp_lcd_new_panel_st7789()'s generic default init. Confirmed from that stream: inversion ON (0x21), RGB order (MADCTL=0x00), offset34,0(esp_lcd_panel_set_gap),LCD_RGB_DATA_ENDIAN_BIG(pairs withlv_conf.h'sLV_COLOR_16_SWAP=1). -
esp_lvgl_port: display registered (lvgl_port_add_disp), LVGL tick/task running. - Ported the LVGL UI (
main/ui_touch.{h,cpp}, all screens: main, settings, config, profile/speed, tuning, endpoint-up/down, wifi, jam, stall sensitivity, batch run) from the originalui_touch.cppontoesp_lvgl_port, wired intoapp_main.cppviabuildUI();motion.cpp's UI hooks now call the real implementations instead of logging stubs. Every public entry point wraps inlvgl_port_lock()/unlock()(recursive mutex - safe to nest) since callers include both the LVGL task andpump_task.- Surfaced two LVGL/Kconfig gotchas along the way, both fixed:
CONFIG_LV_CONF_SKIPdefaults toyin the managedlvgl/lvglcomponent and is misleadingly named (y= "skipinclude/lv_conf.h", not "skip Kconfig") - withoutCONFIG_LV_CONF_SKIP=ninsdkconfig.defaults, our font/color settings silently fell back to Kconfig defaults. And oncelv_conf.hwas actually honored,LV_TICK_CUSTOM=1hid LVGL'slv_tick_inc()declaration thatesp_lvgl_portcalls directly to drive its own tick - fixed by setting it to0(esp_lvgl_port owns the tick, not a custom source). - Known limitation:
on_calibrate()'s "Calibrating..." status text won't reach the screen untilcalibrateEndpointsSensorless()returns (still a synchronous/blocking call) - cosmetic only, revisit once hardware allows visual confirmation.
- Surfaced two LVGL/Kconfig gotchas along the way, both fixed:
- DoD: ✅ verified on a real display+touch module. The JD9853 panel renders the UI correctly on-screen - colours, rotation, offset, fonts all confirmed by eye. Touch works with correct coordinates (button navigation lands on the right elements). Two hardware-surfaced bugs fixed in the process:
- Touch driver: the AXS5106L ACKed its address but every register read returned 0/garbage. Root cause:
i2c_master_transmit_receive()issues an I2C repeated-START, which this chip doesn't support - it needs a STOP between the register-pointer write and the read (the original Arduino driver didendTransmission()+requestFrom(), i.e. two transactions). Fixed with separatei2c_master_transmit/i2c_master_receive; the ID register now reads51 06 01. - Layout: the centre-anchored counter overlapped the top-stacked NOT CALIBRATED badge (inherited from upstream, only visible on the real screen). Counter + batch-remaining label nudged down to clear it.
- Open, but a Phase 4 item: pressing Calibrate blanks the screen. Not a crash (clean recovery, no core dump). Calibration runs on
pump_taskand drives the TMC5160/motor - neither is on this display-only board - so it runs a ~15s fake sensorless search on the SPI bus that the display shares (both onSPI2_HOST), hammering it with StallGuard reads while the display tries to flush. Hypothesis to check first on the bench rig: display flush starved by continuous SG polling over the shared bus during the long calibration move (possibly aggravated by the per-transferspi_device_acquire_bus()added for review #3). Can't be cleanly diagnosed without the motor+TMC attached.
- Touch driver: the AXS5106L ACKed its address but every register read returned 0/garbage. Root cause:
- Vendored
TMC-API(MIT,lib/tmc_api/);main/tmc5160_hal.cppimplements the SPI read/write callback on the shared bus (display CS forced high before every transfer, matching the original). - Ported TMC config (
main/tmc5160_ctrl.cpp: rms_current,sgt,TCOOLTHRS,TPWMTHRS, microsteps, DIAG1) and the SG2DRV_STATUS/SG_RESULTread + median‑of‑5 (reused fromlib/autolee_logic). - FastAccelStepper dropped — verified (not assumed) it requires
arduino-esp32running as an ESP‑IDF component even in "ESP‑IDF mode" (its own README). Replaced with a native RMT + PCNT step generator (main/stepper.*), adapted from Espressif's officialexamples/peripherals/rmt/stepper_motorreference for the pulse encoding, with PCNT hardware step counting for position and a chunked-transmission design forforceStop()(seemain/stepper.h's hardware-verification note for why, and the bounded latency it gives instead of an unverified hard-abort). A TMC5160-internal-ramp-generator alternative (no STEP/DIR at all) was considered and rejected — needs a hardware pin strap that can't be confirmed remotely. ADR 0001's table corrected accordingly. - Ported
motion.cpp's algorithms (calibration phases, sliding‑counter jam detection, homing/creep,handleMotionstate machine) verbatim onto the above (main/motion.cpp) — only the plumbing changed (TMCStepper→tmc5160::, FastAccelStepper→stepper::,millis()/delay()→esp_timer/vTaskDelay), safety logic unchanged. UI hooks (showJamScreen()etc.) are logged stubs pending Phase 3's UI port. - DoD: ⚙️ not yet met — build/boot-verified only, zero hardware verification. No motor/TMC5160 rig was connected while this was written (bare ESP32-C6 board only). Before trusting on the real press: calibration finds UP/DOWN stops; a run cycle; jam detection triggers + safe return‑home; speed‑profile change; and specifically confirm
forceStop()'s actual latency (designed bound: onekCruiseChunkMschunk, seestepper.cpp) is acceptable. Needs a safety-focused code review before that bench session, not just before shipping.- Update 2026-08-09 — a bench session on a bare ESP32-C6 (still no TMC5160, still no
motor) verified the parts that do not need the mechanics: the control-route refusal
contract end to end, the settings-blob v2 → v3 migration carrying counters forward, the SSE
heartbeat at its designed 8 s cadence, and an operator abort interrupting a live
move_until_stall()search and unwinding to IDLE with the position reference latched stale. None of that touches the list above: calibration finding real stops, a run cycle, jam detection + return-home andforceStop()latency all still require the motor rig, and this line stays ⚙️ until they are done.
- Update 2026-08-09 — a bench session on a bare ESP32-C6 (still no TMC5160, still no
motor) verified the parts that do not need the mechanics: the control-route refusal
contract end to end, the settings-blob v2 → v3 migration carrying counters forward, the SSE
heartbeat at its designed 8 s cadence, and an operator abort interrupting a live
Concurrency/motion-safety rework — partially done, bench verification + two items still outstanding (from PR #4 review, findings #2-#8)
The Arduino cooperative loop() became several FreeRTOS tasks (pump_task, LVGL, HTTP, sse_task), so motion state and the shared SPI bus are now touched concurrently with no synchronization. These findings are all facets of that one root cause, touch the same files, and must be fixed together (fixing one in isolation risks half-measures). Deliberately slotted here, not done earlier, because they can only be meaningfully verified with the motor rig attached — the same session + safety review this phase already requires. Re-verified against the repo 2026-07-25 (see docs/review-2026-07-25.md): #2 and #4 were previously marked done together with #8, but only the command-routing half of #8 and the counter half of #4 actually shipped — reopened below. Suggested internal order:
- #2 (command routing half of the foundation): route ALL motion-affecting commands (run/stop/batch/profile/current — not just calibrate/home) through the deferred request-flag/queue pattern (
main/motion/motion_cmd.*) so onlypump_tasktouches the stepper/TMC. Also fixes the TOCTOU on the calibrate guard. - #8 (globals-snapshot half of the foundation): the cross-task motion/endpoint/batch/profile globals now live in one struct,
g_motion(main/motion/motion_state.h), behind oneportMUX_TYPEspinlock.pump_taskowns it (unlocked reads, every write undermotion_state::Guard, grouped so a partial update is never visible); every other task reads a coherent copy viamotion_state::snapshot()—buildStateJSON()(HTTP +sse_task) takes one snapshot per payload, and eachui_touchlabel update takes one before touching LVGL. The handful of writes that still originate on the HTTP/LVGL tasks (endpoint offsets, SG trip, work zone, batch target, counter reset) are read-modify-writes under the same guard. Deliberately left outside the struct, unchanged:rebootRequested/rebootRequestMs(singlevolatileflag + timestamp, documented inglobals.h), the LVGL widget pointers (written once at boot) and the log ring (its own item below). Pure synchronization change — no motion behavior, ordering or timing altered; build verified, still needs the bench session below like the rest of this batch. - #6 + #7 (stepper stop/retarget semantics): manage
s_runninginsidemove_tasksoisRunning()is true for the whole queued move; makerequestGracefulStop()actually interrupt the in-flight move (forceStop()first, or live retargeting) instead of finishing the full stroke; serialize move submission so a secondmoveTo()can't clear as_stopRequesteda concurrentforceStop()just set. - #3 (SPI bus lock): wrap the TMC CS-toggle + transfer in
spi_device_acquire_bus()/release_bus()so a StallGuard read can't interleave with the LVGL display flush on the shared bus (corrupting the safety-path SG value). - #4a (StallCounter/ConfirmCounter): the firmware routes through the already-host-tested
StallCounter/ConfirmCountermodules (lib/autolee_logic/) instead of inline re-implementations. - #4b (motor_fsm now wired): every
runStatechange inmain/motion/motion.cppnow goes throughautolee::motorTransition()via theapplyMotorEventLocked()bridge inmain/motion/motion.h(samemotion_state::Guardtransaction as before —motorTransition()is a pure switch, so it is safe inside the critical section), andmain/motion/motion_cmd.cpp's command gates ask the same table (canStart()/motionEventAllowed()) instead of re-hardcodingrunState == IDLE/RUNNING/STALLED. The tested "can't Start from Stalled" rule is therefore enforced by the shipped firmware. No transition-table/behavior mismatch was found: motion.cpp's real graph is exactly the 9 transitions inmotor_fsm.h(all four blocking entry points — run/stop/calibrate/return-home — were already gated on the matching state bymotion_cmd), so nothing had to be added to or relaxed in the table. Two deliberate details: a rejected event now makes the entry point a logged no-op before it touches the TMC/stepper (previously it would have proceeded — e.g. a stop request fromSTALLEDwould have issued a fastmoveTo(endpointUp)away from a jam), and the jam latch keeps a fail-saferunState = STALLEDif the table were ever to rejectJamfromRUNNING(STALLED is strictly the most restrictive state, so the fallback can only be more conservative). Behavior in every reachable state is otherwise unchanged. Build + host tests (9/9) verified; still inside this phase's bench-verification scope like the rest of the batch. - #5 (OTA handle leak): on every OTA failure path call
esp_ota_abort()and reset the handle/partition/flag (add a stale-OTA timeout). (Self-contained + bench-verifiable without the motor — may be pulled forward into an earlier batch.) - Watchdog config drift: fixed —
CONFIG_ESP_TASK_WDT_TIMEOUT_S=8set explicitly insdkconfig.defaults, the deadENABLE_TASK_WDT/TASK_WDT_TIMEOUT_MSconstants deleted fromconfig.h. -
LogRingthread-safety:lib/autolee_logic/log_ring.hnow guards every public method with a lock (push/clear/serial/head/size/at). On-target (ESP_PLATFORM) that's aportMUX_TYPEcritical section (disables interrupts/preemption for the section); the host build uses a plainstd::atomic_flagspin, which is only safe there becausehost_test/is single-threaded. A first pass used thestd::atomic_flagspin unconditionally, including on-target — caught in review: on this single-core part, a busy-wait with no preemption-disable is a real hang, not just a race — if a lower-priority task (HTTP/sse_task) is preempted mid-section bypump_taskcallingwebLog(),pump_taskspins forever waiting for a task the scheduler can no longer run, tripping the task watchdog mid-motion. Fixed before commit.motion_cmd.cpp's log-clear now calls the newclear()instead of racily reassigning the whole ring. Host tests (9/9) andidf.py buildboth verified after the fix.
Not a blocker for the port itself, but must be decided before the press is used unattended on a shared network:
- #1a: setup AP secured — WPA2 + per-device key + join QR (
b31fe49), replacing the previous open (WIFI_AUTH_OPEN) AP. - #1b: Digest auth (
DIGEST_AUTH) gates every state-changing route via a server-wide method-based middleware (main/net/web_server.cpp:271-300) — confirmed the middleware actually runs before dispatch, not just registration order (lib/psychic_http/src/PsychicHttpServer.cpp:502-508'srunChain()wraps_process()). Reads/SSE are intentionally open;/save+/clearare exempt only in unconfigured AP mode (WPA2 gates access there instead). Documented inapi/openapi.yaml:173-183. - #1c (residual of #1b): factory-default web password
"autolee"(main/config.h:166) persisted until manually changed, and OTA accepted any well-formed image with no identity/signature check (secure boot off) — so one leaked/default password was enough to flash arbitrary firmware. Went with (a) over (b) Secure Boot v2 / signed OTA (provisioning-level, involves irreversible eFuse burns — not doing that casually on the dev board): the OTA image identity check (esp_ota_get_partition_description()project-name compare) landed first; the force-change-on-first-use half now also landed — whiledefaultPasswordis true (GET /api/v1/state's JSON / the SSE state event),main/net/web_server.cpp's server-wide middleware refuses every state-changing route (run, calibrate, OTA upload, etc.) with 403 exceptPOST /api/v1/system/web_passworditself, andmain/net/index_html.h's dashboard shows a persistent "DEFAULT PASSWORD IN USE" banner until it's changed. Documented inapi/openapi.yaml/api/schemas/state.schema.json. - #20: WiFi PSK is stored plaintext in NVS with flash encryption off — recoverable from a flash dump. Enable NVS/flash encryption as part of the (b) provisioning bundle above.
Note: PR #4 review finding #29 (squash the CI-debugging churn commits) is intentionally skipped — this branch will be squash-merged, which collapses the history anyway.
Deferred low-priority cleanups from the PR #4 review (not blocking):
- #17: split
buildUI()(~510 lines) into 11 per-screenbuild_<name>_screen()functions (main/ui/ui_touch.cpp:656-1259):build_main_screen(),build_settings_screen(),build_config_screen(),build_profile_screen(),build_tuning_screen(),build_endpoint_up_screen()/build_endpoint_down_screen()(thin wrappers around the pre-existing sharedbuild_endpoint_screen(scr, title, isUp)helper, which already wired its own widgets),build_wifi_screen(),build_jam_screen(),build_stall_screen(),build_batch_screen().buildUI()itself is now ~25 lines: calls eachbuild_*_screen()in the original construction order, then the unchanged tail (lv_timer_create(counter_timer_cb, ...)+ the initialui_update_*()calls). Revisited the original "conflicts with #8" blocker now that #8 (motion_state consolidation) is long done: re-reading the tail's event-wiring block found every one of its ~16 callbacks referenced only navigation targets (screen pointers likemain_scr/config_scr, alreadyexterninglobals.hsince app_main/motion.cpp/web_server.cpp also touch them) or widgets already promoted to file-scope statics for other reasons before this task started (btn_calibrate,btn_reset_cal,profile_btns[],btn_wifi_reset/skip/back,btn_run_global) — no callback reached into a button handle local to a different screen's block. So the split was purely mechanical: each screen's wiring moved inline right after that screen's widget creation, with zero new file-scope statics needed. Verified withidf.py build(clean) plus a full manual diff review confirming every moved callback body is byte-identical to its original, just relocated — no widget, style, callback logic, or screen-navigation edge changed. - #24: decided — next release is 2.0.0 (bare tags, always full three-part semver:
never
2.0, and never avprefix on the tag itself — semver.org's FAQ: "v1.2.3 is not a semantic version". ESP-IDF's own releases use avprefix but are inconsistent on digit count (v5.5, thenv5.5.1...v5.5.5); this project already rejects that precedent on digit count, so it isn't one to defer to on the prefix either — bare tags also match this project's other repos,git-dyn-semver-gradle-plugin/reqstool-*).CHANGELOG.md's[Unreleased]section already covers this migration in full; it gets renamed to## [2.0.0] - <date>at actual release time per Keep a Changelog convention, once the release tag is cut. Superseded resolution: this was first done by bumping a hand-maintained#define FW_VERSION "2.0"inmain/config.h; that macro has since been removed entirely in favour of the version ESP-IDF already derives from git —esp_app_desc_t.version, auto-populated at build time fromgit describe --always --tags --dirty(noversion.txt, noVERSIONinproject(), noPROJECT_VER). The firmware reads it viaesp_app_get_description()->versionin the boot banner, inbuildStateJSON()(/api/v1/state) and in/api/v1/diagnostics/info, so all three report the identical string. Verified on hardware:"2.0.0"with a bare2.0.0tag at HEAD, a commit hash (plus-dirty) otherwise. Two real bugs caught and fixed along the way: (1) the dashboard used to prepend its own"v"unconditionally, which double-prefixed av-tagged release as"vv2.0.0"— fixed first by rendering verbatim, then, once tags were made bare, restored the"v"prefix as a display-only concern inmain/net/index_html.h, safe now that the underlying value never carries one; (2) the web dashboard'sPOSITION UNCONFIRMEDbanner didn't gate oncalibrated, unlike the touch UI — unrelated to versioning, caught live on hardware in the same session, fixed separately. Cutting 2.0.0 is now justgit tag 2.0.0 && git push --tags+ a GitHub Release — no source file to bump.
- WiFi (
main/wifi_mgr.*): portedwifi_ota.cpp's flow ontoesp_wifi+nvs(STA with saved creds → AP fallback, network scan, credential save/clear). ArduinoOTA dropped (Arduino-only, contradicts staying off Arduino) - OTA is web-upload only, see below. - Captive portal DNS: vendored ESP-IDF's official
dns_servercomponent (lib/dns_server/, fromexamples/protocols/http_server/captive_portal) verbatim rather than hand-writing DNS parsing. Found and fixed a real bug during hardware testing: the vendored code always set the DNS reply's answer-count to the question count, even for questions it didn't actually answer (non-A queries like AAAA/HTTPS-SVCB, or names with no matching rule) — producing a malformed response (reproduced asFORMERRagainst a real client). Fixed to only count/advance past answers actually written; addedESP_LOGIfor every query/answer/skip decision. - ESPAsyncWebServer dropped — verified (by reading its own README and its
idf_component.yml, not the registry listing) that it hard-depends onespressif/arduino-esp32even in "ESP-IDF" mode; being on the ESP Component Registry doesn't imply ESP-IDF-native (the registry also hosts Arduino libraries). Same class of mistake as FastAccelStepper - ADR 0001 corrected. Replaced by PsychicHttp (lib/psychic_http/, vendored, MIT) - a genuinely pure-ESP-IDF async server on top ofesp_http_server, verified via its ownCMakeLists.txt(auto-detects Arduino presence, doesn't require it) and confirmed actively maintained + independently adopted before vendoring. All/api/v1/*routes +INDEX_HTML+ SSE (PsychicEventSource) ported inmain/web_server.cpp;main/index_html.his the original HTML/CSS/JS extracted verbatim (framework-agnostic). - Kept the shared JSON contract unchanged:
state_json.h'sbuildStateJson()reused as-is, just fed from ESP-IDF globals instead of Arduino ones. - OTA: web Firmware-page upload →
esp_ota_ops(esp_ota_begin/write/end/set_boot_partition), wired throughPsychicUploadHandler, replacing Arduino'sUpdateclass. - AP-mode WiFi scan fixed:
esp_wifi_scan_start()requires the STA interface to be active; the fallback AP path used pureWIFI_MODE_AP, so every scan failed silently (WiFi setup page always showed "Scan failed"). Fixed by usingWIFI_MODE_APSTAfor the fallback AP (STA left unconnected, just enabled so scanning works) - matches what Arduino'sWiFilibrary did implicitly. - Captive-portal page dangling-pointer bug fixed:
PsychicResponse::setContent(const char*)stores the raw pointer rather than copying (_body = content;); the WiFi setup page was built viares->setContent(wifiConfigPage().c_str()), a temporarystd::stringwhose buffer is freed beforeres->send()reads it. Reproduced on hardware as garbled/garbage bytes (stray accented characters,@symbols) served instead of the page. Fixed by keeping the string alive in a named local for the request handler's scope. - Dashboard
positionfield fixed:buildStateJSON()hardcodedst.position = 0with a// filled in belowcomment that was never actually followed through - a genuine leftover from the port, not a hardware limitation (stepper::getCurrentPosition()reads the PCNT hardware counter and needs no motor attached). Wired up. - OTA watchdog crash fixed, then fixed properly - found via a real failed OTA upload + core dump decode:
pump_task'sbroadcastState()sent an SSE update every loop iteration, and that socketsend()can block for the full default 5s timeout when a client's connection stalls (weak WiFi, a backgrounded browser tab, or - as first reproduced - a big OTA POST also in flight on the same HTTP server, so the uploading client isn't reading the SSE stream and the TCP send buffer backs up). That's long enough to starve the IDLE task and trip the task watchdog - crashing the device (Panic reason: Task watchdog got triggered ... pump (CPU 0), confirmed viaidf.py coredump-info's thread backtrace showingpumpblocked insidePsychicEventSource::send). First fix was narrow (skip SSE sends during OTA specifically). Generalized immediately after:broadcastState()moved offpump_taskentirely onto its ownsse_task(main/app_main.cpp), deliberately not watchdog-subscribed, since a slow web client blocking there is an expected network condition, not a bug that should panic the whole device.pump_task(which driveshandleMotion()'s jam/homing dispatch) can no longer be affected by SSE client behavior at all. Real-world evidence the generalization was necessary, not just theoretical: between the narrow fix and the task-separation fix, the device crashed again via the exact same watchdog trip while sitting idle with no OTA in progress (confirmed via a stray core dump whose SHA matched that intermediate build) - almost certainly a stale/backgrounded browser SSE connection, not upload contention. Both fixes were verified via a full OTA upload cycle succeeding cleanly afterward. - DoD: ✅ Phase 5 complete - verified end-to-end on real hardware, from a phone genuinely joining the AP and a laptop on the same home network (not just build/boot). Confirmed live: WiFi setup page loads with a real network scan list; saving credentials joins the home network (
wifiStatus: Connected, real IP, confirmed via/api/v1/stateand a live boot log); the DNS captive-portal redirect works against real Android traffic (generate_204correctly gets a302); the full SPA dashboard (/) loads and renders correctly over the network, including live click-through of all four sub-pages (Configuration, Log, Firmware, WiFi); a real profile-switch round-trip via the dashboard; a full OTA upload cycle (upload → success → clean reboot from the other OTA partition → WiFi reconnect) verified multiple times; OTA rollback verified both halves (see Phase 6); and thewebLog()gap closed (see below). One item is out of Phase 5's own scope to close: SSE update cadence during an actual motor run needs a real run cycle, so it carries forward to Phase 4's bench session rather than being a Phase 5 blocker. - Gap found and fixed:
main/motion.cpp's safety-relevant state machine (jam detection, calibration, homing) never calledwebLog()- onlyshowJamScreen()(on-device LVGL) andESP_LOGx(serial), so jam/stall/calibration/homing events never reached the web Log page. Root cause turned out to be a porting mistake, not a missing feature:main's ownAutoLee/motion.h(the Arduino single-translation-unit source this file was ported from) usedwebLog()exclusively for every diagnostic line in this logic - zero rawSerial.printcalls anywhere in it.webLog()itself already callsESP_LOGI("weblog", ...)internally, so one call was always meant to cover both serial and web. The port had silently substituted these with serial-onlyESP_LOGI/ESP_LOGWcalls instead. Fixed by converting every one of them back to a plainwebLog()call (removingESP_LOGx/the now-unusedTAG/esp_log.hentirely from this file), restoring the original one-call-both-destinations design with no duplicated log lines. - Bug found + fixed during bring-up: the pump task's
vTaskDelay(pdMS_TO_TICKS(1))rounds down to 0 ticks at the default 100 Hz tick rate, starving the IDLE task and tripping the task watchdog (reproduced: boot to reboot in ~6.5s). Fixed by using 10ms inapp_main.cpp's pump loop and guaranteeing a minimum 1-tick delay inmotion.cpp'sdelay()helper (used by the calibration/homing polling loops - same failure mode, not yet triggered but same root cause).
- OTA rollback: fully verified end-to-end, both halves.
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y(Phase 1). Added the missingesp_ota_mark_app_valid_cancel_rollback()self-check call inapp_main.cpp, gated onesp_ota_get_state_partition()returningESP_OTA_IMG_PENDING_VERIFY.- Marks-valid half: after a real OTA upload, the post-boot log shows
OTA: image marked valid (rollback canceled), confirming the image really wasPENDING_VERIFYand the check fired correctly - this line correctly never appeared on any of the many direct-idf.py flashboots earlier in this session. - Actual revert half: tested with a deliberately-broken image - temporarily added an
abort()right after the startup log line (before the app could ever reach the valid-mark call), built it, OTA-uploaded it viacurl, and watched the full cycle on serial: the poison image booted fromota_1, logged its startup line, thenabort()'d (full register dump + a fresh core dump saved to flash); on the next boot, the bootloader automatically reverted toota_0with no intervention, booting the last confirmed-valid firmware completely cleanly (WiFi, web server, LVGL all up). The temporaryabort()was reverted immediately afterward (confirmed viagit statusshowing a clean tree) and the real firmware was reflashed over USB as a safety net regardless of the OTA result.
- Marks-valid half: after a real OTA upload, the post-boot log shows
- Watchdog: task + interrupt WDT with panic (Phase 1);
main/motion.cpp'swdt_feed()in the calibration/homing loops,app_main.cpp'spump_tasksubscribes + resets every iteration. Genuinely exercised twice during Phase 5 bring-up: a real bug (0-tickvTaskDelaystarving the idle task), and later a real bug wherebroadcastState()'s blocking SSE send starvedpump_task's watchdog reset (see Phase 5) - both tripped this exact watchdog and force-rebooted the device, confirming it isn't just configured but actually catches hangs. The second bug is also whybroadcastState()now runs on its own non-watchdog-subscribedsse_task:pump_taskis watchdog-critical and driveshandleMotion()'s jam/homing dispatch, so nothing whose latency depends on a network client should ever share that task - a design rule worth keeping in mind for any future work inapp_main.cpp/web_server.cpp. - Core dump to flash - fully verified end-to-end: deliberately crashed the device (null-pointer write), confirmed via serial log it panics ("Guru Meditation Error... Store access fault"), saves to the
coredumppartition, and survives reboot; then decoded it withidf.py coredump-info, which pinpointed the exact crash line (app_main.cpp:64) with a full backtrace and thread dump. Reproduced across 6+ crash/save/reboot cycles. - Brownout threshold:
CONFIG_ESP_BROWNOUT_DET=y(Phase 1, default threshold). Tuning it against the actual 24V→5V + stepper draw needs the physical press - not done. - DoD: ✅ core dump and OTA rollback both fully verified (see above). Brownout tuning still needs real hardware (the press's power supply under load) - not done.
- CI: confirmed green in real GitHub Actions runs on PR #4 (not just locally) -
idf.py build(viaespressif/install-esp-idf-action, pinned to ESP-IDF v6.0.2 - originally v5.3.2), host tests (host_test/ctest + gcovr coverage), and lint (pre-commit's clang-format/ruff + Spectral on the API specs + a JSON Schema contract check). Actions pinned to commit SHAs (verified live viagh api, not from memory). Originally oneci.yml; since split intobuild.yml(firmware + host tests) andlint.yml(pre-commit, API specs, workflow lint, cliff.toml's bump mapping) so a formatting nit and a broken build report as separate checks and can be re-run separately.- Getting host-tests green took 4 real iterations against actual CI:
install-esp-idf-action's cross-toolchain (Xtensa/RISC-V) leaked intohost_test/'s native host build'sas/ldresolution. PinningCC/CXX, resettingPATH, and additionally resettingCOMPILER_PATHall failed identically (as: unrecognized option '--64'), implying a system-level change (e.g. a gcc specs file), not a process-env one. Fixed by not using that action for this job at all -host_test/only needs one file from ESP-IDF (components/unity/unity, itself aThrowTheSwitch/Unitysubmodule), fetched directly pinned to the exact commit ESP-IDF references, verified in a fully clean (env -i) local simulation before trusting it in CI. (v5.3.2 and v6.0.2 point the submodule at the same Unity commit, so the 6.0 bump needed no change here.)
- Getting host-tests green took 4 real iterations against actual CI:
- Release (
.github/workflows/release.yml):idf.py build→idf.py merge-bin -o ...-factory.bin --pad-to-size 4MB(verified by hand: produces exactly a 4 MB raw image, matching the README's "flash at offset 0x0" instructions) + the app-only image as...-ota.bin, both attached to the GitHub Release. The oldFW_VERSION-must-match-tag guard has been dropped: the version is now derived fromgit describeat build time, so a clean checkout of tagX.Y.Z(bare, always full three-part semver) reports exactly that tag by construction and there is nothing left to cross-check — the workflow uses the tag only to name its artifacts. Since rewritten as aworkflow_dispatchpipeline (validate → checks → approval gate → tag/build/prerelease → verify the published assets → promote to latest), with the version and the release notes derived from the Conventional Commits by git-cliff (cliff.toml) and the version read back out of the built image bytools/app_desc.py. Not yet run - untested until an actual release is cut;dry-run(the default) exercises everything up to the checks. - Docs: README/CONTRIBUTING/CLAUDE.md already rewritten for the ESP-IDF-only layout as part of the Phase 0-3 rebase (see the
refactor!commit); nothing further pending here. - v1.10.0 (was "v1.9"): Karl's upstream release shipped, diffed, and folded into this port - see
docs/upstream-v1.10.0-diff.mdand the PR description's "Upstream v1.10.0 merge" section. Confirmed incorporated: three inherited bugs fixed (batch counting gated by the display counter cap, decel-blank carry-through discarding jam evidence, unescaped&in the WiFi scan dropdown), plus the TMC tuning delta (TOFF5→4,TBL=1,INTPOL) - flagged unverified on hardware, still a Phase 4 bench item below. - DoD: ✅ CI is green (verified in real GitHub Actions, not just claimed). Release workflow's build steps verified by hand; the end-to-end release flow untested (needs an actual release to exercise). v1.10.0 diff incorporated.
Non-bench-blocking follow-ups from the 2026-07-25 codebase review (docs/review-2026-07-25.md).
The safety-critical, bench-blocking items from that review are tracked in Phase 4 above, not here.
- UX/UI/bug review follow-ups (2026-08-08/09). Five PRs: the batch-start gate on a stale
position reference; the dashboard's silent
SG=0write, invisible SSE dropout, per-pixel slider POSTs and counter-parity; control routes reporting400/409instead of a blanket200 ok(newlib/autolee_logic/command_gate.h); a true persisted lifetime cycle count (settings blob v3); and operator-abortable calibration/creep-home (newMotorEvent::Abort). Findings judged not worth fixing now were filed as issues rather than left implicit — command coalescing, confirmation-pattern consistency, web-UI cleanups, and STOP reachability from every screen. Sequence diagrams for the resulting flows are inFLOWS.md. - Persist settings/calibration to NVS as a versioned struct (
main/settings_store.{h,cpp}, registered inmain/CMakeLists.txt:6). One fixed-layoutPersistedblob (PersistedV1,main/settings_blob.h:74) in NVS namespaceautolee, keysettings,uint16_t versionfirst:rawUp/rawDown/endpointsCalibrated/upOffsetSteps/downOffsetSteps, the per-profilesg_trip[],runCurrentMa,sgWorkZoneSteps,activeProfile,counter. Deliberately not persisted:runState,currentTarget,batch*and the SG telemetry counters (must always come up at safe defaults after any reset),SpeedProfile::name/speed_hz(aconst char *is meaningless across a firmware update, andspeed_hzhas no runtime writer), and the derivedendpointUp/endpointDown(recomputed from the restored raws).- Load (
main/settings_store.cpp:162) runs fromapp_main.cpp:93, beforemotion_init(), so the restoredrunCurrentMais whatmotion_init()pushes to the TMC5160; it finishes withrecomputeEffectiveEndpoints(). - Fail-safe, all-or-nothing: a missing blob, a size mismatch, a
versionmismatch, or any field outside the bounds the live setters already enforce (OFFSET_MIN/MAX,RUN_CURRENT_MIN/MAX,SG_WORK_ZONE_MIN/MAX,RUN_SG_TRIP_MIN/MAX,NUM_PROFILES, plus a geometry check that travel is positive and withinCAL_SEARCH_STEPS) discards the whole blob (validateV1(),main/settings_blob.h:111), keepsMotionState's compiled-in defaults and explicitly forcesendpointsCalibrated = false+ zeroed effective endpoints (fallbackToDefaults(),main/settings_store.cpp:117), logging the reason viawebLog(). Nothing is partially trusted — the press must never believe it knows its endpoints from data that failed a check. - Save (
main/settings_store.cpp:247) is a dirty-check hook at the end ofprocessPendingCommands()(main/motion/motion_cmd.cpp:156), i.e. pump_task only:memcmpagainst the last-saved copy, at most one write per 5 s (kSaveIntervalMs), so a held-down UI knob costs one commit rather than one per step. An NVS commit is a few ms — three orders of magnitude inside the 8 s task-WDT budget, and pump_task resets the watchdog on the next iteration. - Residual closed: a restored calibration now requires a Return Home before it will run.
The stepper's position counter starts at 0 every boot while the carriage sits wherever power
was lost. 0 is the UP endpoint in the normal case (a finished run, a stop and a home all park
there), but after a mid-stroke power cut or watchdog reset the restored endpoints are offset
from reality — so the restore is no longer merely logged, it is gated:
MotionState::positionReferenceStale(main/motion/motion_state.h:62) is latched bysettings_store::apply()(main/settings_store.cpp:61) whenever a calibration is restored, and byfallbackToDefaults()(:117) for good measure. It is cleared only by something that re-establishes ground truth against the UP hard stop: a successfulsafeCreepHome()(main/motion/motion.cpp:458, inside the same guard as theHomeDonetransition, and not on the "FAILED to find stop!" path) or a freshcalibrateEndpointsSensorless()(main/motion/motion.cpp:708, in the same transaction asendpointsCalibrated = true).startRunBetweenEndpoints()refuses while it is set (main/motion/motion.cpp:155), logging "Start refused: position reference unconfirmed - return home first" and returning before it touches the TMC or the stepper — same shape as the existing!endpointsCalibratedand rejected-transition early-outs.- The tested FSM gained a second source state for
ReturnHome:Idle + ReturnHome -> Homing(lib/autolee_logic/motor_fsm.h:46, alongsideStalled + ReturnHome -> Homingat:60), so re-referencing does not require a full recalibration — the DOWN search stays valid as long as UP is correctly re-zeroed.motion_cmd'ss_returnHomehandler already gates onmotionEventAllowed(MotorEvent::ReturnHome)(main/motion/motion_cmd.cpp:73), so it picked the new source state up with no change. - Operator affordance: the main-screen warning banner (
ui_update_main_warning(),main/ui/ui_touch.cpp:188) now shows "TAP: RETURN HOME" and becomes clickable in that state (same object/style as "NOT CALIBRATED"; the tap defers to pump_task viamotion_cmd::requestReturnHome()exactly like the jam screen's button, which is otherwise the only Return Home in the UI and is unreachable from IDLE). The state JSON gainedpositionStale(lib/autolee_logic/state_json.h:30/:74,buildStateJSON()main/net/web_server.cpp:124,api/schemas/state.schema.json+state.example.json), and the web dashboard reuses the jam-alert panel to surface it (main/net/index_html.h:368). - Covered by
host_test/test_motor_fsm(the exhaustive transition sweep knows the new valid pair) and 4 newhost_test/test_motion_seqcases: Start refused while stale, Return Home from IDLE clears it and unblocks Start, a failed home keeps it, a fresh calibration clears it.
- Load (
- Versioned-settings migration chain (README-Copilot review item #4, "Version your settings",
docs/review-2026-07-25.md:60) — the persisted blob was versioned but had no migration: aversionmismatch took the same all-or-nothing reject path as corruption, so the first field addition after deployment would have silently wiped every device's calibration. There is now a real forward-migration mechanism.- New pure header
main/settings_blob.h— the on-flash layouts, the per-version validators and the version dispatch, split out ofsettings_store.cppand deliberately free of NVS,MotionStateand logging so the host tests exercise exactly what the firmware runs (same split aslib/autolee_logic/, but it stays undermain/because it depends onmain/config.hforNUM_PROFILESand the validation bounds).settings_store.cppkeeps the NVS I/O and theMotionStateapply halves only. - Layouts:
struct PersistedV1(main/settings_blob.h:74) is the byte-for-byte 36-byte layout that shipped,static_assert'd at:87and frozen — old-version structs are never edited, they describe bytes that exist on deployed devices.using Persisted = PersistedV1(:93) is what the firmware works with in RAM; a new version repoints that, notPersistedV1.kMaxBlobBytes(:98) sizes thenvs_get_blob()read buffer against the largest known version, soload()reads raw bytes and lets the dispatch decide what they mean rather than reading straight into today's struct (main/settings_store.cpp:176). - Dispatch:
peekBlobVersion()(:172) reads only the leadinguint16_t—versionis the first member of every layout by construction,static_assert'd at:173— andparseAndMigrate()(:185) then switches on it: current version → direct deserialize, older version → deserialize with that version's struct and run the chainedmigrate_vN_to_vN+1()steps, anything else (too old, or from the future after an OTA rollback) →ParseResult::UnknownVersion. Multi-version jumps chain single steps (v1→v2→v3); no shortcut functions. - Corrupt old blobs cannot be laundered forward: each version has its own validator
(
validateV1(),main/settings_blob.h:111— the oldvalidate()moved verbatim, same bounds the live setters enforce) and every step validates its input on its own version's terms before migrating, so a bad v1 blob is rejected rather than turned into a v2 struct that passes every v2 check.outis left untouched on every failure path. kVersiondeliberately stays 1. Nothing is being added to the persisted set here — the mechanism is what was missing. The bump to 2 belongs to the health-monitoring item (stall count, cycle stats), which will be its first real user. To stop that bump shipping without its migration,static_assert(kVersion == 1, ...)(main/settings_blob.h:218) fails the build ifkVersionmoves withoutparseAndMigrate()'s switch being extended — verified by temporarily bumping it and watching the build stop.- Recipe is documented where it will be found (
main/settings_blob.h:20, the file header): the seven steps to add a persisted field, from "never edit the old struct" through the new validator, the one migration function, the newcase, and the test. - Load-path behaviour is otherwise unchanged.
load()(main/settings_store.cpp:162) mapsParseResultback onto the samewebLog()reasons as before (size mismatch / version mismatch / failed validation →fallbackToDefaults()), so a current-version blob loads exactly as it did. New: on a successful migration it logsSettings: migrated from vN to vM(:211) and immediately rewrites the blob in the current format (:243) — afters_lastSavedis baselined, so a failed rewrite just leaves the old bytes andtick()retries. - Tests: new
host_test/test_settings_blob(9 cases, suite wired viahost_test/test_settings_blob/suite.cmake). Every "stored blob" is assembled as raw little-endian bytes at hand-written offsets rather than bymemcpy-ing a struct, so the suite is an independent description of what is on flash and catches a layout edit the struct's ownsizeofassert would miss (test_v1_layout_is_frozen,:104). Covers the unchanged common case (test_v1_blob_loads_unchanged,:141), the uncalibrated blob, version 0/2/0xFFFF rejection, truncated/short/long blobs, and 11 out-of-range field rejections that each assertoutwas left untouched. Because there is no real v2 yet,test_recipe_migrates_an_old_blob_forward(:387) andtest_recipe_rejects_a_corrupt_old_blob_before_migrating(:403) rehearse the documented recipe on stand-in structs (namespace recipe,:279) with a genuine two-step v1→v2→v3 chain, proving carried-over fields survive, new fields get explicit defaults and a corrupt old blob is rejected before any migration runs. Replace that pair with the real migration's test when the health-monitoring bump lands. - Verified:
idf.py buildclean; all 11 host suites pass; and on the real board a v1 blob written by the previous firmware (runCurrentMaset to 2800 mA overPOST /api/v1/motion/current) was restored correctly after flashing the new build —I (592) weblog: Settings: restored (cal=no up=0 dn=0 cur=2800mA prof=1 cnt=0), no migration line, no fallback.
- New pure header
- Reset calibration/settings action — a deliberate way to discard a stored calibration
(press moved, brass changed, a clean recalibration wanted) without an NVS erase over serial.
- Core:
settings_store::resetToDefaults()(main/settings_store.h:72,main/settings_store.cpp:264). Erases the blob withnvs_erase_key, nevernvs_erase_all— theautoleenamespace is shared:wifi_mgr.cppkeepsssid/pass/apkeyandweb_server.cppkeepswebpassin it, so only thesettingskey is ours to delete and a reset can never lock the device off the network (the misleading "own namespace, not wifi_mgr's" comment atmain/settings_store.cpp:16was corrected to say this). - Defaults come from
MotionStateitself, not from literals:applyCompiledDefaults()(main/settings_store.cpp:96) copies the persisted subset out of a default-constructedMotionState, somotion_state.hstays the single source of truth and a new persisted field cannot be silently left behind.fallbackToDefaults()(:117) now just calls it plus logs — the rejected-load and the deliberate-reset paths share one field list. Calibration is forced off, the effective endpoints zeroed andpositionReferenceStalelatched, thenrecomputeEffectiveEndpoints()runs, exactly as at the end ofload(). - Dirty-check bookkeeping is defined, not accidental (
main/settings_store.cpp:264): on a successful erase,s_lastSavedis re-baselined to the defaults ands_lastCheckMsreset, so thetick()call at the very end of the sameprocessPendingCommands()pass sees no diff and does not immediately re-create the blob — the next boot takes the same "no stored settings" path as a factory-fresh device. If the erase fails, the defaults are written over the stale blob instead (RAM and flash must not disagree, or the old calibration would come back on the next boot); if that write also fails,s_lastSavedis deliberately left at the pre-reset copy sotick()'s dirty check keeps retrying. - Routing:
motion_cmd::requestResetSettings()(main/motion/motion_cmd.h:37,main/motion/motion_cmd.cpp:53) — one more atomic flag, executed on pump_task only (:144). Gated on the literalg_motion.runState == IDLErather thancanStart()/motionEventAllowed(): this is not a motion command, and coupling it to the motion-permission table would silently widen the gate if that table ever gains a state. pump_task then re-programs the hardware from the restored defaults (tmc5160::rms_current()on the display-shared SPI bus,setActiveProfile()for the stepper speed) and refreshes the UI. A non-IDLE request is logged and dropped. - API:
POST /api/v1/settings/reset(main/net/web_server.cpp:654), already covered by the existing Digest-auth middleware like every other write; documented with the other action values inapi/openapi.yaml:104. - Web UI: a "Reset Calibration" section at the end of the Configuration page
(
main/net/index_html.h:171) with aconfirm()guard inresetSettings()(:401), the same client-side patternresetWifi()uses. - Touch UI: a red "Reset Cal" button, last on the (scrollable) Config screen
(
main/ui/ui_touch.cpp:746) — deliberately off the main/settings screens and out of the way of normal operation. Confirm step: two-tap arming (on_reset_cal(),:599), because this UI has no modal-dialog pattern to reuse (the existing destructive buttons, "Reset WiFi" and "Reset Count", fire on the first tap) and a dedicated confirm screen would be a lot of machinery plus another navigation dead-end on a 172x320 display. The first tap turns the button amber and relabels it "Sure? Tap" and starts a one-shotUI_CONFIRM_ARM_MS(5 s,main/config.h:141) timer; only a second tap inside that window fires. The timeout, entering the screen and leaving it all disarm (:1107,:1166), so a stray tap can never wipe a calibration and the operator is told what the next tap will do. - Verified:
idf.py buildclean, and all 10host_test/suites still pass (this touches nolib/autolee_logic/code;main/motion/motion.cpp's host-tested sequencing is unchanged).
- Core:
- Faked the
stepper::/tmc5160::seams —motion.cpp's sequencing now has host-test coverage (host_test/fakes/+host_test/test_motion_seq/, 34 Unity tests, 100% line coverage ofmain/motion/motion.cppper gcovr — note CI's coverage gate still filters onlib/autolee_logic/only, so this figure is informational, not enforced).main/motion/'smotion.cppandmotion_state.cppare compiled verbatim into the new suite (not extracted, not reimplemented — a behaviour-preserving change by construction, since the firmware sources are byte-identical andidf.py buildis unaffected): what is replaced is everything below them — fakestepper::/tmc5160::bodies behind the realmain/drivers/*.hheaders (so a seam signature change breaks the test build instead of silently drifting), fakewebLog()/ui_touch.hhooks, and minimalesp_timer/esp_task_wdt/vTaskDelay/portMUX/lvgl.hstubs underhost_test/fakes/include/. The stepper fake models the two things the sequencing depends on: moves take time (forceStop()only takes effect on the next poll, matching the one-cruise-chunk latencystepper.cppdocuments) and position is a pulse count (PCNT keeps counting while the carriage sits against a hard stop — which is exactly what StallGuard sees as a stall), so calibration/homing hit detection is driven by a simulated press with real mechanical stops rather than by canned SG values.host_test/CMakeLists.txtgrew one generic extension point (a suite may add asuite.cmakefor extra sources/includes); the other nine suites are unchanged.- Now covered: the jam path end-to-end (SG trip →
forceStop→ creep speed → backoff by exactly ±RUN_BACKOFF_STEPSaway from the target endpoint →STALLEDlatch → jam screen, plus "no further move is commanded after a jam" andhandleMotion()being inert inSTALLED/HOMING/CALIBRATING); accel-blank, work-zone and single-high-reading debounce (no false jam); endpoint-arrival cycle counting, target flip, batch completion → graceful stop, and the counter-saturation-must-not-stall-a-batch regression; graceful stop reaching UP vs. timing out afterSTOP_TIMEOUT_MS; the fullcalibrateEndpointsSensorless()order (cal current → pre-move → UP search → back off → re-zero → DOWN search → back off → restore run current → park at UP) and its clean-failure path when no stop is found;safeCreepHome()find-stop/re-zero/return-to-IDLE and its failure path;return_home_up_safe()'s retry counting (3 stalls →HOME_MAX_RETRIESgive-up) andHOME_TIMEOUT_MS; every FSM-rejection guard (start/stop/calibrate/return-home from a state that forbids it must touch no hardware); and — asserted intearDown()for every test — that no stepper/TMC/webLogcall ever happens inside amotion_state::Guardcritical section, the rulemotion_state.hdocuments. - Still NOT covered: anything below the seam (
main/drivers/stepper.cpp's RMT/PCNT pulse generation,tmc5160_hal.cpp's SPI/CS handling,axs5106l_touch,display_touch), real concurrency (the host build is single-threaded:portENTER_CRITICALonly counts nesting, so these tests prove the discipline, not the absence of races), real timing/accel behaviour (the fake ramps instantly and advances the clock in 10 msvTaskDelaysteps), and the actual SG signal a jammed press produces. Bench verification of calibration/jam/homing on the real press (Phase 4) is still required — this suite catches sequencing regressions, it does not replace the bench session.
- Now covered: the jam path end-to-end (SG trip →
- Added
GET /api/v1/diagnostics/infodiagnostics endpoint (main/net/web_server.cpp:444-472):esp_app_get_description()(version, idf_ver, compile date/time, ELF SHA-256 truncated to the first 8 bytes/16 hex chars — matching ESP-IDF's own boot-log short SHA),esp_reset_reason()mapped to a string via a localresetReasonStr()(web_server.cpp:70-90), free/min-free heap (esp_get_free_heap_size()/esp_get_minimum_free_heap_size()), uptime (esp_timer_get_time()/1000), the running OTA partition's label (esp_ota_get_running_partition()), and acoredumpPresentbool (esp_core_dump_image_check() == ESP_OK). It's a GET, so — like every other read in this file — it's left open, not Digest-gated (matches the existing convention: only non-GET routes go throughs_auth). Documented inapi/openapi.yaml. - Added
GET /api/v1/diagnostics/coredump(main/net/web_server.cpp:475-512) — streams the rawcoredumppartition (partitions.csv, verified end-to-end in Phase 6) as a chunked file download via PsychicHttp'ssendChunk/finishChunking, reading throughesp_partition_readin 512-byte chunks afteresp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_COREDUMP). Presence/integrity is checked withesp_core_dump_image_check()(validates the stored checksum) before anything is streamed — returns a clean 404 instead of any bytes if no valid coredump is present, and the actual byte count comes fromesp_core_dump_image_get(). Digest-gated, unlike/api/v1/diagnostics/infoand every other GET in this file — caught in review before commit: a coredump is a raw RAM snapshot and can contain the WiFi or web password in plaintext (both are held in local C buffers at runtime —wifi_mgr.cpp'swifi_config.sta/ap.password,s_auth's stored password). The shared auth middleware (web_server.cpp'saddMiddleware) special-cases this one route to require auth despite being a GET. Added a "Download Core Dump" link next to the OTA upload control on the web UI's Firmware page (main/net/index_html.h) — a plain<a href>, so the browser handles the download natively.espcoredumpandesp_partitionadded tomain/CMakeLists.txt'sREQUIRES. Documented inapi/openapi.yaml. - OTA image identity check (
main/net/web_server.cpp'shandleOtaUpload(), lines 260-300) — one half of#1cabove (the other half, force-change-on-first-use for the default password, has since also landed — see#1cabove). Beforeesp_ota_set_boot_partition(), and while the OTA handle is still open (i.e. beforeesp_ota_end()),esp_ota_get_partition_description()reads the just-written partition'sesp_app_desc_tand compares itsproject_nameagainst the running app's own (esp_app_get_description()). A mismatch is rejected via the existingotaAbort()helper (handle still open, soesp_ota_abort()— notesp_ota_end()— is correct) and logged both toESP_LOGEandwebLog(); boot partition is never set. A same-project image proceeds through the existingesp_ota_end()→esp_ota_set_boot_partition()path unchanged. - SSE diff-and-heartbeat instead of unconditional full-state every 250ms
(
main/net/web_server.cpp'sbroadcastState(), lines 714-740).buildStateJSON()'s output is now compared againsts_lastSentState; the defaultmessageSSE event is only sent when it differs. When it doesn't, a lightweight namedheartbeatevent ({}) is sent instead onceSSE_HEARTBEAT_MS(8s, new constant inmain/config.h) has elapsed since the last send of either kind — so a client can tell "still connected, nothing changed" apart from a dead connection without a full-state payload every 250ms regardless of whether anything moved. The dashboard'sonmessagehandler only fires for the unnamed event, so the heartbeat is inert to existing JS. TheMotionState/buildStateJSON()data model is unchanged.api/asyncapi.yamlupdated with the newheartbeatmessage and the revised send-cadence description. - Fixed the stale
.pre-commit-config.yamlexclude path — was^main/stepper_motor_encoder\.[ch]$, now^main/drivers/stepper_motor_encoder\.[ch]$matching where the file actually lives. - Firmware version switched to the git-derived
esp_app_desc_t.version(git describe), replacing the hand-maintainedFW_VERSIONmacro that was previously bumped to"2.0"(see#24above). - REST API regrouped from flat
/api/v1/*into nested paths —motion/*,settings/*,wifi/*,diagnostics/*,system/*;GET /api/v1/stateand the SSE/api/v1/eventsstay top-level. The oldPOST /api/v1/action?do=<verb>dispatcher is gone, split into four standalone routes (motion/calibrate,motion/return_home,motion/reset_counter, andsettings/reset, which moved out of motion into its own group). Started as "just add OpenAPItags:so Swagger UI/Redoc group cleanly", but on rubber-ducking it, the two things that made a full regroup look risky both turned out to be defensible: thesystembucket (password/log/OTA are genuinely device administration, not motion) and theactionsplit (each verb is its own resource; thedo=query param was only ever a way to save URI handlers). Safe to do now because no release has been cut — zero git tags, zero GitHub releases, so there is no external consumer to break. The one real risk wasmain/net/web_server.cpp's server-wide auth middleware, which does exact string equality on two literal paths: the#1cwrite-gate exemption for the password endpoint (get it wrong and a device on the factory-default password can never have it changed over the network again) and the Digest-despite-being-a-GET gate on the coredump download (get it wrong and the coredump silently becomes unauthenticated). Both are testable with two curls, and both were verified on hardware after the rename. Changed in lockstep:main/net/web_server.cpp,main/net/index_html.h(dashboard fetches, OTA target, coredumphref;doAct()now takes a path instead of a verb),api/openapi.yaml(paths + per-grouptags:,actionsplit into four documented operations),api/schemas/state.schema.json,lib/autolee_logic/state_json.h,tools/mock_server.pyand the doc references.api/asyncapi.yamlis unaffected (only/api/v1/events).max_uri_handlersraised 26 → 32 to cover the three extra routes.
Explicitly deferred (rationale in docs/review-2026-07-25.md's Copilot-comparison section, not
repeated here): splitting main/ into ESP-IDF components/ (already native), esp_event
pub/sub (would undo the deliberate single-writer motion design), a diagnostics ZIP download (no
filesystem partition by design), a full HAL abstraction (already ~90% done via
lib/autolee_logic/), named-string profile enums in the API (breaking change for cosmetic gain).
- DoD: items tracked but not blocking; Phase 4's safety items are the actual bench-session blockers, not these.
Phases 3, 4, and the full‑flow parts of 5–6. Everything in 0, 1, 2 and most of 5, 7 is hardware‑independent (compile + host‑test here) and can go first.
lib/autolee_logic/ (carries over as‑is), src/web_server.cpp INDEX_HTML (the web UI),
src/config.h constants, src/motion.cpp / ui_touch.cpp / wifi_ota.cpp (algorithms &
screens to port), api/ (contract), and the vendored driver folder (swap the Arduino variant
for the IDF variant).
ADR 0001 · docs/review-2026-07-25.md (post-migration codebase review,
source of Phase 8 and the Phase 4 corrections above) ·
WaveShare ESP32‑C6‑Touch‑LCD‑1.47 ESP‑IDF demo (display + touch init) ·
TMC-API · grblHAL Trinamic driver ·
esp_lvgl_port ·
ESPAsyncWebServer (IDF component) ·
ESP‑IDF captive_portal + http_server SSE examples.
Implemented (build-verified + network-verified, NOT motor-verified):
- Deferred command layer (
main/motion/motion_cmd.*): every motion-affecting request from the HTTP task and the LVGL task is queued and executed only onpump_task. Verified over the network that profile/current/batch changes are applied bypump_task. - SPI bus lock around every TMC transfer (
spi_device_acquire_bus). - Stepper retargeting so a graceful stop interrupts the in-flight move, and the
stop flag is owned by
move_taskrather than clobbered bymoveTo(). - Jam detection + calibration/homing confirmation now run through the
host-tested
StallCounter/ConfirmCounter(tested == shipped). The same is now true for the run-state transitions themselves:motorTransition()/canStart()(motor_fsm.h) are called frommotion.cppandmotion_cmd.cpp— see #4b above. - Karl's TMC tuning applied: INTPOL, and TOFF=5/TBL=2 as of upstream v1.28 (v1.10.0 had TOFF=4/TBL=1).
Still requires the motor/TMC5160 rig before it can be trusted:
- Calibration finds both mechanical stops.
- A full run cycle between endpoints.
- Jam detection actually triggers, backs off, and returns home safely.
-
forceStop()/ retarget latency measured (design bound: onekCruiseChunkMscruise chunk OR one accel/decel curve, whichever is in flight - curves transmit in a single shot and cannot be interrupted mid-curve). Make this the first bench measurement — the bound is currently design-asserted only, and it's the number that determines how far the press travels after a jam is confirmed. - The TMC tuning values confirmed against the real press (torque/smoothness).
- A safety-focused review of the whole motion path before the first run.
- Toolchain moved 5.3.2 → 6.0.2, floor raised to
idf: ">=6.0"inmain/idf_component.yml; CI (build.yml) andrelease.ymlpinv6.0.2.dependencies.lockregenerated (LVGL 8.4.0 andesp_lvgl_port2.8.0 resolve unchanged - no component bumps were needed). - Three source changes, all that 6.0 required:
main/drivers/display_touch.cpp— explicitfreertos/FreeRTOS.h+freertos/task.h; 6.0 stopped re-exporting them through theesp_lcd/driverheaders, sovTaskDelay()/pdMS_TO_TICKS()injd9853_send_init_sequence()no longer resolved.main/drivers/display_touch.cpp—LCD_DC/CS/RST_GPIOareGPIO_NUM_*rather than bare ints; 6.0 typedesp_lcd's cs/dc/reset config members asgpio_num_t, which C++ will not implicitly narrow an int into.main/net/wifi_mgr.cpp— a scoped-Wmissing-field-initializerspragma aroundDNS_SERVER_CONFIG_SINGLE, which 6.0 rejects under-Werror. Kept at the call site solib/dns_server/stays byte-identical to Espressif's example, where the macro is unchanged in 6.0.2.
- Notably NOT needed: ST7789 stayed in
esp_lcd(only NT35510 moved to the registry in 6.0),esp_private/esp_clk.hstill exists, and the vendoredlib/psychic_http+lib/tmc_apineeded nothing. - Verified on hardware (WaveShare ESP32-C6,
/dev/ttyACM0): clean boot on 6.0.2 — NVS settings restored across the upgrade (boots=14), AXS5106L probed over I²C, LVGL UI built, stepper driver enabled, WiFi associated, web server up,app_main()returned./api/v1/stateand/api/v1/diagnostics/infoboth answer and reportidfVersion: v6.0.2. The screen itself is a visual check the maintainer still has to make. - Host tests: 11/11 pass against 6.0.2's Unity (same submodule commit as 5.3.2, so CI's pin did not move).
- Cost: app image grew 1,570,384 → 1,648,352 bytes (+78 KB, +5.0%), leaving 16% of the OTA
slot free (was 20%). Attributed by diffing
idf.py size-componentsbetween the two builds:- mbedTLS 4.x + the PSA Crypto migration, ~+45 KB —
libmbedcrypto.a(72,208 B) is replaced bylibtfpsacrypto.a(117,201 B). This is the dominant cause and it is called out in 6.0's own release notes ("Forhttp_server/simple, the flash impact increases about 41KB (~4.97%)"). - WiFi/PHY blobs, ~+28 KB —
libnet80211+14.0 KB,libwpa_supplicant+8.6 KB,libphy+2.9 KB,libpp+2.5 KB. - libc, ~+5.6 KB — Picolibc (6.0's new default) is advertised as a size reduction; measured
here it is a small increase (
libnewlib+libc10,246 B →libesp_libc+libc15,858 B), presumably becauseCONFIG_LIBC_PICOLIBC_NEWLIB_COMPATIBILITYdefaults on. So the lever if the slot ever gets tight is mbedTLS, not libc. Worth knowing: this firmware serves plain HTTP and uses Digest (MD5) auth — mbedTLS is pulled in becauselib/psychic_httprequiresesp_https_server, not because anything here terminates TLS.
- mbedTLS 4.x + the PSA Crypto migration, ~+45 KB —