Skip to content

Migrate firmware build to PlatformIO, restructure docs#8

Merged
yamanote1138 merged 9 commits into
mainfrom
platformio-migration
Jul 17, 2026
Merged

Migrate firmware build to PlatformIO, restructure docs#8
yamanote1138 merged 9 commits into
mainfrom
platformio-migration

Conversation

@yamanote1138

Copy link
Copy Markdown
Owner

Summary

  • Replaces the arduino-cli / manual mklittlefs+esptool toolchain with PlatformIO across build.sh and CI. PlatformIO derives the LittleFS partition geometry directly from board_build.ldscript on every build (verified: packed image is exactly 2,072,576 bytes / 253 blocks, matching docs/LITTLEFS.md's known-good math) — this removes the hand-maintained size constant that caused a silent filesystem-reformat bug in the past.
  • Splits the README into a scannable front page + condensed quickstart, moving the full prerequisites/build-script reference to docs/BUILD.md and the WebSocket protocol spec to docs/PROTOCOL.md.
  • Bumps version to 2.3.0 (firmware, frontend package.json/lock).

Test plan

  • pio run compiles cleanly (RAM 40.0%, Flash 36.5% — sane numbers)
  • pio run -t buildfs packs a LittleFS image at exactly 2,072,576 bytes (253 blocks, matches docs/LITTLEFS.md)
  • frontend && npm run build still succeeds into build/data/
  • ./build.sh option 3 (build firmware) runs end-to-end through the new PlatformIO-backed script
  • CI workflow YAML validated (Ruby's YAML parser)
  • Not done this session — no device connected: flash ./build.sh option 7 to a real Wemos D1 Mini and confirm boot/WiFi/mDNS/WebSocket/UI all still work before fully trusting this in production

Replaces arduino-cli/mklittlefs/esptool with PlatformIO across build.sh
and CI. PlatformIO parses the FS partition geometry directly from
board_build.ldscript (eagle.flash.4m2m.ld) on every build, removing the
hand-maintained mklittlefs size constant that caused a silent LittleFS
reformat bug in the past (see docs/LITTLEFS.md).

Also splits the README into a front page with a condensed quickstart,
moving the full prerequisites/build-script reference to docs/BUILD.md
and the WebSocket protocol spec to docs/PROTOCOL.md.

Bumps version to 2.3.0.
Verified end-to-end on a real Wemos D1 Mini: firmware + LittleFS flash,
boot, WiFi/mDNS, and the full WebSocket protocol (status/ping/speed/stop)
all confirmed working via the PlatformIO pipeline. 921600 baud upload
failed intermittently ("Timed out waiting for packet header" / "Invalid
head of packet") on this USB-serial adapter; 460800 flashed reliably.
@yamanote1138

Copy link
Copy Markdown
Owner Author

Full hardware verification complete — flashed to a real Wemos D1 Mini via ./build.sh → option 7:

  • Firmware + LittleFS both flash successfully (had to drop upload_speed from 921600 → 460800; the faster rate was unreliable on this USB-serial adapter — pushed as a follow-up commit)
  • Boot log confirms LittleFS.begin() mounts cleanly on the freshly-flashed 2MB partition (no "mount failed") — the core migration risk, verified
  • WiFi connects, mDNS resolves, HTTP server serves index.html + static assets from LittleFS (200 OK)
  • WebSocket protocol fully exercised: connect → status broadcast, pingpong, speed → status update, stop → status update — all match docs/PROTOCOL.md exactly

Ready to merge.

… frontend builds

Secrets: arduino_secrets.h (a gitignored C header, #include'd into the
sketch) is replaced by secrets.ini, a gitignored PlatformIO config file
merged in via extra_configs. Values become plain -D build_flags, so the
firmware code needs no changes beyond dropping the #include. Verified
end-to-end on real hardware: WiFi/mDNS/HTTP/WebSocket all work identically
with credentials sourced from secrets.ini instead of the old header.

Frontend builds: PlatformIO's own incremental compile already makes
re-running `pio run` on unchanged firmware near-instant, but `npm run
build` has no such skip. build.sh now checks mtimes of frontend source
files against the existing build/data/ output and skips the Vite build
for options 1 and 7 when nothing changed (option 2 stays an explicit,
unconditional rebuild). Verified both the skip and rebuild paths.
@yamanote1138

Copy link
Copy Markdown
Owner Author

Added two follow-ups, both re-verified end-to-end on hardware:

Secrets → secrets.ini. Replaced arduino_secrets.h (gitignored C header, #included into the sketch) with secrets.ini (gitignored PlatformIO config merged via extra_configs, values become -D build_flags). Confirmed a real flash boots with WiFi SSID/mDNS hostname correctly sourced from secrets.ini, and HTTP + WebSocket both still work. One gotcha found and fixed along the way: values containing spaces (e.g. a two-word railroad name) need '"..."' (single-quote-wrapped double-quotes), not \"...\" — the latter breaks PlatformIO's flag tokenizer on the space. secrets.ini.example documents this.

Skip redundant frontend builds. build.sh options 1/7 now skip npm run build if nothing under frontend/ is newer than the existing build/data/ output (mtime-based check). Option 2 stays an explicit, unconditional rebuild. Verified both the skip path and the rebuild-on-change path.

…gration

- docs/LITTLEFS.md: replace FQBN/arduino-cli framing with the actual
  board_build.ldscript mechanism; fix the "find your linker script"
  instructions (arduino-cli command no longer applies)
- docs/BUILD.md: correct the secrets.ini build_flags example to the
  working '"..."' quoting (was still showing the broken \"...\" form)
- CONTRIBUTING.md: de-duplicate the WebSocket protocol table in favor
  of linking to docs/PROTOCOL.md, the single source of truth
@yamanote1138

Copy link
Copy Markdown
Owner Author

Docs audit: checked all 8 markdown files (README, CONTRIBUTING, CLAUDE.md, docs/BUILD, HARDWARE, PROTOCOL, LITTLEFS, status-led-wiring) for stale references and broken links post-migration. All internal links resolve; no arduino-cli/arduino_secrets.h/old-version mentions remain outside deliberate historical context. Fixed three real issues:

  • docs/LITTLEFS.md still framed things around the old FQBN/arduino-cli toolchain in two spots (including a "find your linker script" command that no longer works) — updated to describe the actual board_build.ldscript mechanism
  • docs/BUILD.md was still showing the broken \"...\" secrets quoting instead of the working '"..."' form
  • CONTRIBUTING.md duplicated the full WebSocket protocol table that now canonically lives in docs/PROTOCOL.md — replaced with a link to avoid future drift between the two

…ch warnings

- z-duino.ino: fix comment referencing the old arduino_secrets.h
- Extract buildStatusJson() so broadcastStatus() and the WStype_CONNECTED
  handler stop duplicating the same ~10-line JSON construction
- Add default: break to both webSocketEvent's and StatusLED::setState's
  switches, eliminating all -Wswitch warnings on unhandled enum values
- StatusLED now stores the active test color and handles LED_TEST in
  setState(), fixing a real (if minor) bug: previously, triggering an
  emergency stop while in LED test mode left the LED dark afterward
  instead of restoring the test color, since setState(LED_TEST) hit no
  matching case

Verified: pio run compiles with zero warnings; reflashed to hardware,
confirmed boot/WiFi/mDNS unaffected and the WebSocket protocol (connect
status, ping/pong, speed, stop) behaves identically to before.
…ampTo

- tsconfig.json: remove the @/* path alias — nothing in the codebase
  uses it, every import is relative
- SpeedController.vue: merge two separate import statements from the
  same useTrainController module into one
- useTrainController.ts: rampTo() now returns a Promise that resolves
  when its own ramp completes (naturally or via emergency stop),
  replacing waitForRampComplete()'s 50ms polling loop that watched the
  shared rampTimer variable. toggleDirection() awaits rampTo() directly.

Note: investigated removing the unused `vue-router` dependency (nothing
in the app imports it) but reverted — @nuxt/ui's Link.vue override
component still references it internally, and Vite's optional-peer-dep
stubbing doesn't cleanly resolve its named imports (useRoute, RouterLink)
during a production build with this @nuxt/ui/Vite version combo. Kept as
a real (if only build-time) dependency.

Verified: npm run build succeeds; exercised rampTo/toggleDirection at
runtime via a stubbed-WebSocket harness (no test framework in this repo)
— ramp-down, pause, direction flip, and ramp-up all sequence identically
to the pre-refactor behavior, with the promise resolving at the correct
point (ramp-down + DIRECTION_PAUSE).
@yamanote1138

Copy link
Copy Markdown
Owner Author

Applied the simplification pass, all verified:

Firmware (all four): fixed the stale `arduino_secrets.h` comment; extracted `buildStatusJson()` to remove the ~10-line duplicate between `broadcastStatus()` and the `WStype_CONNECTED` handler; added `default: break` to both switches (zero `-Wswitch` warnings now); and fixed a real minor bug found while tracing the LED_TEST warning — `StatusLED` now stores its test color and restores it correctly after an emergency-stop blink sequence, instead of leaving the LED dark. Recompiled clean, reflashed, confirmed boot/WiFi/mDNS/WebSocket protocol all unaffected.

Frontend (3 of 4 applied, 1 reverted): removed the dead `@/*` tsconfig alias, merged the duplicate `SpeedController.vue` import, and replaced `waitForRampComplete()`'s polling loop with `rampTo()` returning a real `Promise` — verified at runtime with a stubbed-WebSocket harness (no test framework exists in this repo), confirming ramp-down → pause → flip → ramp-up sequences identically to before, promise resolving at the correct point.

vue-router removal reverted. Looked genuinely unused from application code (confirmed: zero imports, @nuxt/ui lists it as an optional peer), but removing it broke the production build — @nuxt/ui's internal `Link.vue` override still imports `useRoute`/`RouterLink` from it, and Vite's optional-peer-dep stubbing doesn't cleanly resolve those named imports at build time with this version combo. Correcting my earlier audit finding: it's a real (if only build-time/transitive) dependency, not dead weight.

…rmIO native

Firmware logic was fused with hardware I/O (pinMode/digitalWrite/WiFi/socket
calls alongside the actual decision logic), making it untestable without
real hardware. Extracted the hardware-independent pieces into lib/ (auto-
linked into both the real d1_mini env and a new native test env, confirmed
via scratch testing this session):

- lib/MotorLogic — the forward/reverse/stop decision (speed + direction +
  invert -> action), previously inline in applyMotorState()
- lib/LedTiming — StatusLED's breathing-brightness and blink-timing math,
  previously inline in StatusLED::update()
- lib/Command — WebSocket JSON command parsing/dispatch decision,
  previously inline in handleWebSocketMessage() (uses ArduinoJson, which
  is header-only and builds fine on native)

z-duino.ino and StatusLED.cpp now just apply whatever these pure functions
decide — identical runtime behavior. One acceptable change: malformed-JSON
logging lost the specific parser error string in favor of a generic
message, since that detail no longer crosses the pure/impure boundary.

Added [env:native] (platform = native, test_framework = unity) plus
default_envs = d1_mini so plain `pio run`/build.sh are unaffected. 38 Unity
test cases across test/test_motor_logic, test/test_led_timing, and
test/test_command, all passing via `pio test -e native` (no device needed).

Verified: pio run (real target) still compiles clean; reflashed to the
connected Wemos and re-ran the full WebSocket protocol regression (every
command type, including the LED_TEST + emergency-stop combo) — no runtime
behavior change from the extraction.
…tests

Nearly all real logic in this app lives in useTrainController.ts (per the
earlier simplification audit) — Vitest is a natural fit given the existing
Vite setup.

- test/setup.ts: a FakeWebSocket (happy-dom, like jsdom, doesn't implement
  the WebSocket API), assigned to global.WebSocket, since the composable
  opens a real socket at module-load time.
- test/useTrainController.test.ts: speed segment/ramp logic, toggleDirection
  sequencing (ramp down -> pause -> flip -> fire-and-forget ramp up) using
  fake timers to validate the rampTo-returns-a-Promise refactor from
  earlier this session, direction-invert persistence via localStorage, LED
  brightness scaling, and reconnect backoff doubling/capping/reset.
- test/SpeedController.test.ts: mount the component, verify segment
  coloring from a simulated status message and that E-Stop sends the
  right command. Needed @nuxt/ui's Vite plugin in vitest.config.ts too
  (not just @vitejs/plugin-vue) since UButton/UIcon are auto-imported.

Test isolation: useTrainController.ts is a singleton (module-scoped state),
so every test does vi.resetModules() + a fresh dynamic import rather than
reusing the shared module-level state across tests.

`npm test` (13 tests) and `npm run build` both verified passing.
- ci.yml: firmware job runs `pio test -e native` after compiling;
  frontend job runs `npm test` before the production build.
- CONTRIBUTING.md / CLAUDE.md: document the new test commands, the
  lib/ + test/ structure, and that hardware-touching code (pins,
  WiFi/sockets) is intentionally not covered by unit tests.
@yamanote1138

Copy link
Copy Markdown
Owner Author

Added practical test coverage to both firmware and frontend, per request. Neither had any before this.

Firmware (38 Unity test cases, `pio test -e native`, no hardware needed): extracted the hardware-independent logic that was previously fused with pin/WiFi/socket calls into `lib/` — `MotorLogic` (forward/reverse/stop decision), `LedTiming` (breathing/blink timing math), `Command` (WebSocket JSON parsing/dispatch, using ArduinoJson which builds fine on native). `z-duino.ino`/`StatusLED.cpp` now just apply what these pure functions decide — identical behavior. Added `[env:native]` + `default_envs = d1_mini` so plain `pio run`/`build.sh` are unaffected (verified). Reflashed to real hardware afterward and re-ran the full WebSocket protocol regression (every command type) to confirm the extraction changed nothing at runtime.

Frontend (13 Vitest tests, `npm test`): `useTrainController.test.ts` covers speed segments, the `toggleDirection` ramp/pause/flip sequencing (validates the `rampTo`-returns-a-Promise refactor from earlier, using fake timers), direction-invert persistence, LED brightness scaling, and reconnect backoff. `SpeedController.test.ts` mounts the component for two smoke tests (segment coloring, E-Stop command). Needed a `FakeWebSocket` (happy-dom doesn't implement one) and per-test `vi.resetModules()` since the composable is a module-scoped singleton.

Both suites now run in CI (firmware job: compile → native tests; frontend job: `npm ci` → tests → build). Docs (`CONTRIBUTING.md`, `CLAUDE.md`) updated with the new structure and commands.

@yamanote1138
yamanote1138 merged commit 631e27c into main Jul 17, 2026
2 checks passed
@yamanote1138
yamanote1138 deleted the platformio-migration branch July 17, 2026 17:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant