Skip to content

Add RH-style visual marshal with SD RSSI history capture - #15

Merged
LouisHitchcock merged 7 commits into
mainfrom
feature/VisualMarshalGraph
Sep 12, 2026
Merged

LouisHitchcock merged 7 commits into
mainfrom
feature/VisualMarshalGraph

Conversation

@LouisHitchcock

Copy link
Copy Markdown
Owner

Adds a RotorHazard-style visual marshal for reviewing and editing lap times.

Supersedes #13, which GitHub closed automatically when this branch was renamed
from MarshalVisualGraph to feature/VisualMarshalGraph to match the other
feature/* branches. Same work, same commits from 4939d56 down, plus the
follow-ups below.

Original scope

  • Record filtered RSSI during races to an SD sidecar with a small RAM staging buffer, exposed via /api/marshal/rssi
  • Race history Marshal UI to review RSSI peaks and edit lap times
  • Graph zoom/pan, drag-to-place markers, Ctrl multi-select delete
  • Theme-aligned styling, Gate/Lap labels, collapsible help
  • Refresh race history timeline and analytics after lap edits

The WIP concerns from #13 are resolved

Both issues raised on the original PR have been fixed and verified on hardware:

Heap on /api/marshal/rssi. The handler built the whole sample array as one
contiguous String reserved at samples * 4 + 96, on top of two full copies of
the sample buffer, and request->send() copied it again. At the 45,000-sample
cap that document is 160,899 bytes. It is now emitted incrementally through
beginChunkedResponse, and loadSamples trims the header off the buffer it
already read instead of allocating a second one.

SD writes in the lap-detection loop. Staging was flushed from inside
handleLapTimerUpdate, so every 512 samples the loop could block for up to
200 ms on the SPI mutex plus the card write. The recorder now owns three staging
buffers, a write queue and a low-priority writer task; addSample only posts a
full buffer and takes a fresh one with zero wait. Race end no longer reads and
rewrites the whole sidecar either — it patches the 12 header bytes in place.

Verified on an AIO with a 12-minute race, roughly 36,000 samples.

Also in this branch

One race editor. Viewing a race offered both Edit and Marshal, which did
substantially the same job. Marshal is now the single editor, renamed Edit, with
the race name, tag, distance and notes it was missing in a side panel. Races with
no RSSI sidecar get typed lap entry where the graph would be, so they remain
editable.

8 MB partition table. spiffs was 1 MB, sized in v1.3.1 when data/ was
around 650 KB. At v1.7.3 it is 907 KB and the filesystem image no longer builds.
It is now 3.875 MB, using flash that was previously unallocated. nvs, otadata,
app0 and app1 keep their offsets and sizes, so OTA app updates still land
correctly. This affects all seven 8 MB targets and main needs it independently
of this PR.

Race ordering. Nothing in the load path sorted by date, and race files are
named DDMMYY-HHMMSS, so a race from 2 August sorted ahead of one from
11 September and the newest races appeared last. Now sorted newest-first.

Release note required

Devices that OTA-update only the app keep their old 1 MB partition table and
cannot take a 3.875 MB filesystem image. Those users need one full reflash. This
must be called out in the release notes for whichever version ships it.

Record filtered RSSI during races to an SD sidecar with a small RAM
staging buffer, expose samples via /api/marshal/rssi, and add a race
history Marshal UI to review peaks and edit lap times.
Add graph zoom/pan, marker drag placement, Ctrl multi-select delete,
theme-aligned styling, Gate/Lap labels, collapsible help, and refresh
race history timeline/analytics after lap edits.
Review follow-ups on the Visual Marshal branch.

/api/marshal/rssi built the whole sample array as one contiguous String
reserved at samples*4+96, on top of two full copies of the sample buffer,
and request->send() copied it again. At the 45000-sample cap that document
is 160,899 bytes, so a long race could not be served. It is now emitted
incrementally through beginChunkedResponse by MarshalRssiStream, and
loadSamples trims the header off the buffer it already read instead of
allocating a second one.

MarshalRssiStream returns 0 only once the document is complete, because
ESPAsyncWebServer treats a zero-length chunk as end of body. A host-side
harness checks its output byte-for-byte against the previous encoding for
chunk sizes from 1 to 1 MB, including every ragged size up to 300.

RSSI staging was flushed to SD from inside handleLapTimerUpdate, so every
512 samples the lap-detection loop could block for up to 200 ms on the SPI
mutex plus the card write. The recorder now owns three staging buffers, a
write queue and a low-priority writer task; addSample only ever posts a
full buffer and takes a fresh one with zero wait. If the writer falls
behind the sample is dropped and counted rather than stalling detection.

Dropped samples are no longer added to sampleCount, which would have
skewed the graph's time axis, and finalizeHeader now trusts the on-disk
byte count and patches the 12 header bytes in place instead of reading and
rewriting the whole sidecar at the end of every race.

Also: strip a UTF-8 BOM from racehistory.cpp; check data.status in
marshalSaveLaps, since the endpoint answers 200 with {"status":"ERROR"} on
a failed write; and fall back to English per key in i18n.t(), because the
marshal strings exist only in en.json and de/es/fr/zh-CN users would
otherwise see raw keys such as history.marshal_title.
spiffs on custom_8mb.csv was 1MB, sized back in v1.3.1 when data/ was around
650KB. data/ has since grown to 907KB on v1.7.3, and LittleFS rounds every file
up to a 4096-byte block, so the AIO image now lands at roughly 95% full and any
further UI work fails to build with "No more free space".

Nothing was using the 2.94MB of the 8MB chip past 0x510000, and the layout was
lopsided: the 4MB SuperMini already gets 1.4MB of filesystem and the 16MB
XIAO S3 Plus gets 2MB, so the 8MB boards had the smallest filesystem of any
target. spiffs is now 0x3E0000 (3.875MB), which takes the image to about 25%
full, and the last 64KB becomes a coredump partition so panics leave a
backtrace.

nvs, otadata, app0 and app1 keep their original offsets and sizes, so an OTA
app update still lands correctly and saved settings survive. spiffs keeps its
0x410000 offset, so the documented esptool command and the per-release
FLASH_INSTRUCTIONS.txt are unchanged.

Devices that OTA-update only the app keep their old 1MB table and need one full
reflash before they can take a filesystem image built against this one. That
needs calling out in the release notes for whichever version ships it.

Verified: FPVGateAIO, ESP32S3 and FPVGateSolo all build a 4,063,232 byte image.
Viewing a race offered both Edit and Marshal, which did substantially the same
job from two different modals. Marshal is now the single editor, renamed Edit,
with the race details it was missing.

Race name, tag, total distance and notes move into a side panel beside the lap
list, so the graph keeps the width it needs. Save now writes both, posting
/races/update for the details and /races/updateLaps for the laps. Both endpoints
answer 200 with {"status":"ERROR"} on a failed write, so a shared assertSaved
helper checks the body rather than the HTTP status; the old Marshal save
reported success regardless.

The Marshal button used to be conditional on the race having an RSSI sidecar,
so races recorded before this feature, or with no SD card in, only ever had the
Edit button. With one editor those races would have lost lap editing entirely,
so they now get typed entry in seconds where the graph would be, with Add Lap
and per-row delete. The two lap lists are alternatives and never appear
together.

Typed edits are held in a copy on marshalState rather than in raceHistoryData,
so closing without saving no longer leaves the cached race mutated, and the
inputs are read back before any re-render so adding a lap cannot discard edits
to the other rows. Both were bugs in the edit modal this replaces.

Removes the standalone #editRaceModal and 171 lines of superseded JavaScript.

tests/race-editor.test.js brace-matches segmentsToAbsPasses and
absPassesToSegments out of data/script.js and runs the real implementations,
covering the lossless round trip, out-of-order markers sorting back to positive
laps, typed-entry validation and the history gate. jest 37/37.
Race History showed recent races below much older ones, and with enough races
on the card the newest could vanish from the web UI entirely.

Nothing in the path sorted by date. Storage::listDir returns names in raw
openNextFile() order, RaceHistory::loadRaces appended them as they came,
toJsonString then emitted only the first MAX_RACES entries of that order, and
the front end assigned the array through untouched. Race files are named
DDMMYY-HHMMSS.json, so even a clean alphabetical listing sorts by day of month
before month and year: a race from 2 August (020826) sorts ahead of one from
11 September (110926), which is exactly why the newest races appeared last.

saveRace inserts at the front, so ordering was right during a session and only
broke after a reload. That is why this surfaced now: loadRaces previously ran
only in setup() with LittleFS mounted, so races on the SD card were never loaded
at boot. Reloading once the card mounts finally gave the missing sort something
to get wrong.

Sorting by timestamp descending fixes both symptoms, since the MAX_RACES cap
then keeps the newest races rather than an arbitrary selection.

The load message is now an unconditional Serial.printf that reports the count
and says when the cap is trimming what the web UI receives, because working out
how many races were actually on the card needed a reflash to answer.

Verified on an AIO: GET /races returns 5 races in strict newest-first order,
and the self test reports storage type SD with race history 5/50.
@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 51 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 396d4b10-90ba-451b-9ad0-4a9935a7c2c4

📥 Commits

Reviewing files that changed from the base of the PR and between d5e8677 and 8f4b4ab.

⛔ Files ignored due to path filters (1)
  • custom_8mb.csv is excluded by !**/*.csv
📒 Files selected for processing (17)
  • data/calib-styles.css
  • data/i18n.js
  • data/index.html
  • data/locales/en.json
  • data/script.js
  • lib/LAPTIMER/laptimer.cpp
  • lib/LAPTIMER/laptimer.h
  • lib/RACEHISTORY/race_rssi_recorder.cpp
  • lib/RACEHISTORY/race_rssi_recorder.h
  • lib/RACEHISTORY/racehistory.cpp
  • lib/RACEHISTORY/racehistory.h
  • lib/STORAGE/storage.cpp
  • lib/STORAGE/storage.h
  • lib/WEBSERVER/webserver.cpp
  • src/main.cpp
  • tests/i18n-fallback.test.js
  • tests/race-editor.test.js

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@LouisHitchcock
LouisHitchcock merged commit 869eae7 into main Sep 12, 2026
3 checks passed
@LouisHitchcock
LouisHitchcock deleted the feature/VisualMarshalGraph branch September 12, 2026 14:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant