Waveform capture → cloud (design)
Status: shipped, firmware v0.8.44 (cross-repo: foodoublebassesp32 + foogolf-ota-cloud).
v3 (fw 0.8.166, 2026-08-03) — the URL batch is gone
The device now fetches ONE presigned S3 URL per shot, on demand, in
ShotUploader::enqueue()on the main loop. No batch, no ring, no prefetch, and no cap on shots per power cycle.The v2 batch below prefetched 48 URLs in a single boot-time TLS call. That imposed a hard 48-shot limit per power cycle and made "why did uploads stop?" needlessly hard to answer — the state that explained it lived in a ring buffer filled minutes earlier. It existed only because per-shot TLS was unaffordable (mbedTLS needs ~30–40 KB contiguous internal heap; the device had ~43 KB), and v0.8.163 removed exactly that pressure by moving LVGL's 48 KB pool out of internal RAM into PSRAM (static RAM 210128 → 160976).
Trade-off accepted deliberately: each upload now blocks the main loop for a TLS handshake. It must — the NetTask worker's stack is in PSRAM and can't run mbedTLS. It lands between scans, right after a shot, never during capture, and uploading is a diagnostic mode. A heap gate (
kMinTlsMaxAlloc, 40 KB) drops the shot rather than attempt a handshake that would fail and fragment the heap on its way out — so the failure mode that motivated v2 is guarded, not ignored.Notable consequences: - The per-device upload-level mask and the
uploads_disabledtoggle now refresh on every shot instead of at boot, so a dashboard change takes effect on the next shot. -uploads_disabledstill latches until restart, so a disabled device doesn't pay a pointless handshake per shot. - PUT retries reuse the same URL (valid 12 h), so a transport blip can't consume a budget. - The "URLs left" line is gone from the Wi-Fi status page — there's no remaining budget to report. - PSRAM use drops: the response buffer goes ~88 KB → 4 KB, the URL store ~81 KB → 3.4 KB.The boot OTA check-in runs before any shot uploads, so these handshakes cannot break that boot's update path; the next boot starts from a fresh heap regardless.
CURRENT ARCHITECTURE (v2 — supersedes the "Architecture" section below)
The original design had the device POST signed metadata to
/record-shot(TLS) which returned a presigned PUT URL. That doesn't work on this hardware: the device has only ~43 KB contiguous internal heap, an mbedTLS handshake needs ~that, and a per-shot TLS upload fragments the heap until even OTA check-in fails (-17040 RSA/BIGNUM memory allocation failed). See [[esp32-internal-heap-tls-constraint]].v2 path (no per-shot TLS): 1. Device fetches a BATCH of presigned S3 PUT URLs via one TLS call to
POST /get-upload-urls(HMAC-signed), done at idle on the main loop, gated onESP.getMaxAllocHeap() >= 40 KB+ throttled. URLs held in PSRAM (ring buffer, batch of 8). 2. On a GOODSWING, the firmware serializes the host frame and PUTs it to the next presigned URL over plain HTTP (no TLS, light on heap — safe even right after a shot). 3.index_shotLambda (S3 ObjectCreated onfoogolf-waveforms/shots/) reads the frame's JSON header (Range request) and writes thefoogolf-shotsrow — replaces/record-shot.Because uploads no longer use TLS, the heap doesn't fragment and OTA/check-in stays healthy.
record_shotLambda was removed from the stack on 2026-09-11 (superseded; last invoked June 2026). The S3 object format and thefoogolf-shots/dashboard schema are unchanged.SELECTABLE UPLOAD LEVELS (added firmware v0.8.134, 2026-06-25)
Originally only
GOODSWINGswings uploaded (hard-coded inShotProcessor::beginShot). The dashboard can now opt a device into uploading other message levels too — chiefly to diagnose hardware (e.g. turn onBADSWINGuploads to inspect a suspected miswired sensor).
- Selectable set: only the levels whose frames carry a valid, normalised waveform —
GOODSWING,BADSWING,IMPLAUSIBLE(the same setHostCommunicator::sendvalidates; NOISE/ERROR/IDLETIMEOUT keep raw un-normalised timestamps and are never offered). The allow-list is duplicated in three places that MUST stay in sync: firmwareShotUploader::parseUploadLevels, cloudget_upload_urls, cloud dashboardupdate_device.- Control path (reuses the existing upload-toggle plumbing): dashboard level chips →
update_devicewritesupload_levels(a list) on thefoogolf-devicesrow →get_upload_urlsreturnsupload_levelsalongside the presigned URLs → firmware parses it into a level bitmask. Default (field absent) =["GOODSWING"], so behaviour is unchanged until the operator widens it. An empty set is treated like the masteruploads_enabled:false(device goes idle, no stall warning).- Firmware gate:
ShotUploader::levelEnabled(level)drives the enqueue. Normal-mode GOODSWING uploads viaShotProcessor::beginShot(the only level that enters the shot pipeline; it sends to the host directly). Every non-GOODSWING verdict (BADSWING/IMPLAUSIBLE) is emitted from one of several scan-loop reject paths (edge-peak, no-pulse/direction/polarity, implausible, fall-through), each of whichcontinues after callingApp::sendToHost. So the enqueue lives insideApp::sendToHost— the single verdict chokepoint they all pass through — NOT at the fall-through only. (v0.8.134 hooked just the fall-through, so reject-path bad swings like a miswired sensor's "no pulse" never uploaded; fixed in v0.8.135.) Default GOODSWING-only, so a non-enabled level pays zero serialization cost. enqueue() does no network on the scan loop — it posts to the core-0 NetTask worker, preserving scan-loop isolation.- Apply latency — IMPORTANT: the device reads
upload_levelsonce, at the boot URL prefetch (the one TLS window this hardware can afford). So a dashboard change takes effect only on the device's next power-cycle. This matches the existinguploads_enabledtoggle semantics.The sections below are the original v1 design, kept for history.
Cross-repo feature spanning foodoublebassesp32 (firmware) and foogolf-ota-cloud (backend).
Goal
Capture per-swing waveform data from the device and store it in the cloud for later analysis. A UI for browsing/visualising waveforms will come later — this phase only lands the data.
Decisions (made 2026-06-14)
- What to upload: good swings only (
level == GOODSWING). Mishits/noise are not uploaded. - Reliability: best-effort. If Wi-Fi is down or the upload fails, drop the shot. No persistent queue, minimal RAM footprint. We may lose shots; that is acceptable for v1.
- Build order: cloud first (deploy + curl-test the endpoint), then firmware.
- Transport to S3: plain HTTP (not TLS). The ESP32-S3's mbedTLS cannot complete a TLS
handshake to S3 from the device's network path — this is already the case for OTA firmware
download (see
lambdas/get_firmware_download/app.pyheader comment). The presigned PUT URL is therefore generated against thehttp://s3.us-east-1.amazonaws.comendpoint. Waveform data is not sensitive and the presigned URL is short-lived, so unencrypted transport is an acceptable, deliberate trade-off — consistent with the existing OTA download decision.
Architecture
Mirrors the existing OTA pattern: small signed JSON through API Gateway + Lambda, large binary blob direct to S3 via a presigned URL (never through Lambda — avoids base64 inflation, the HTTP API payload ceiling, and Lambda transfer cost).
ShotProcessor::beginShot (firmware)
├─ forward to integration (existing)
├─ send USB JSON+binary frame (existing)
├─ if GOODSWING && WiFi connected: enqueue cloud upload ◄── NEW (background task)
└─ UI hand-off (existing)
cloud upload (background FreeRTOS task, best-effort):
1. POST /record-shot (signed JSON metadata, ~3-5 KB)
→ Lambda verifies HMAC, writes foogolf-shots row, returns presigned S3 PUT URL
2. HTTP PUT waveform blob → S3 (foogolf-waveforms bucket), direct, plain HTTP
The upload runs on a background task so a slow/absent Wi-Fi link never stalls the shot-result page or the UI hand-off.
Auth
Reuses the existing device HMAC scheme verbatim — X-Device-Id / X-Timestamp / X-Signature
headers, signature = HMAC-SHA256(secret, f"{ts}." + raw_body), secret looked up from the
foogolf-devices table. See _verify_signature in check_for_update/get_firmware_download.
The /record-shot Lambda duplicates that function (each Lambda is its own deployment package,
matching the repo's existing no-shared-layer convention).
Storage
S3 — foogolf-waveforms (new bucket)
Separate from foogolf-firmware (different lifecycle — firmware is versioned+retained forever;
waveforms may get a retention/expiry policy later). Encrypted (AES256), public access blocked.
- Key:
shots/{device_id}/{shot_id}.bin - Object body: the exact host-protocol frame the firmware already builds in
HostCommunicator(sync word + JSON header + per-sensor binary waveform/peaks + CRC32). Reusing that format means the future UI can decode it with the existing JavaFXSensorMessageJsonDecoder+ binary layout rather than a new format. shot_idis generated server-side by/record-shotand embedded in the presigned URL, so the device just PUTs to the returned URL and never computes the key itself.
DynamoDB — foogolf-shots (new table)
PAY_PER_REQUEST, encrypted, PITR on. Holds the queryable metadata so the future UI can list/filter shots without fetching blobs.
- PK
device_id(S), SKshot_id(S, sortable: server time-based) - Attributes:
created_at(ISO),s3_key,level, promoted metrics (club_speed_mps, face angle, etc.),waveform_sizes, andmeta(the full SensorMessage JSON as a string — it's a few KB, well under the 400 KB item limit, and saves an S3 fetch for the UI).
Per-swing size
~100–150 KB typical, ~490 KB worst case (4 sensors × up to 10 000 samples × 12 B/sample + peaks + JSON). Fine for S3; the blob never touches DynamoDB (400 KB item limit) or Lambda.
Cloud changes (foogolf-ota-cloud)
template.yaml: addWaveformsBucket,ShotsTable,RecordShotFn(POST /record-shot) withDynamoDBCrudPolicy(shots) +DynamoDBReadPolicy(devices, for HMAC verify) +S3CrudPolicy(waveforms bucket, for presigning PUT). New env varsSHOTS_TABLE,WAVEFORMS_BUCKET.lambdas/record_shot/app.py: verify HMAC, write shot row, return presigned PUT URL.- Deploy via the review-first SAM changeset flow (never blind
sam deploy).
Firmware changes (foodoublebassesp32, after cloud is live)
- New
src/CLOUD/ShotUploader.{h,cpp}: background task; on a good swing POST signed metadata to/record-shot, then PUT the host frame to the presigned URL. Best-effort, drop on failure. - Hook in
ShotProcessor::beginShotafter the USB send, gated onGOODSWING+ Wi-Fi connected. - Reuse
Signing::addSignedHeaders,OtaConfig.hbase URL,AwsRootCa.hfor the API Gateway TLS. - Bump
Version.h, cut a prod OTA release (RUNBOOK), commit + push all four repos.