FooGolf developers
Documentation › docs/device-control-and-support-cast-design.md

Device control channel + remote support cast (2026-09-13, fw 0.8.499 / 0.8.500)

Design record for two things shipped together on 2026-09-13 (firmware 0.8.499 to staging; foogolf-ota, foogolf-dashboard and foogolf-mirror-syd stacks updated by review-first deploys; dashboard frontend redeployed):

  1. A device control channel - the dashboard writes a device-row field and the device notices within 30 s instead of at its next boot.
  2. The remote support cast, rebuilt on the LAN cast - the dashboard's Support page shows a device's screen and presses its buttons, started from the dashboard itself.

The cost side (per-device budget, retention) is cloud-cost-budget-per-device.md. The retired predecessor ("Remote support cast", the cloud relay of 0.8.186-0.8.492) is recorded in serverless-screen-mirror-design.md and foodoublebassesp32/docs/removals-2026-09-11.md; its LAN section 9 is what this design reuses.

1. Why a control channel

Before 0.8.499 nothing polled the cloud unless Online play was hosting or joined: the OTA check-in is deterministic (reset, WiFi connect, manual check - never a timer, ota-stuck-2026-08-postmortem.md), the waveform uploader fetches its config per shot only while uploads are ON (a device switched OFF never fetches, so OFF -> ON waited for a reboot), and the OTA channel was adopted at the boot check-in. "Start remote support from the dashboard, immediately if possible" needs a channel that exists whenever the device is powered.

2. The control poll

3. Telemetry events

Best-effort usage record for every customer's device, the thing the cost budget explicitly allows: SimShare::noteEvent(json) queues a compact JSON object (<= 120 chars, ring of 24, oldest dropped when full) and the next control poll carries up to 20; the cloud acks how many it stored and the device drops exactly those (a failed poll retries them). Kinds:

k when fields
boot the boot gate opens fw
game Menu_StartExperience (every Play / New / Resume / Basic view, a guest following its host) m = gameMode
shot ShotProcessor::beginShot (a good swing) mps, face, rot, aoa

Every event carries t (epoch seconds, 0 before SNTP - the cloud stores "now" for a stamp more than a week off). Rows land in foogolf-events (device_id, ts = ms + index) with a one-year TTL (expires); numeric fields are clamped and rounded to 3 dp, unknown fields dropped. No dashboard view yet - the table is for Athena-style analysis later.

4. Remote support cast

What is reused, and what the old relay got wrong

The browser cannot reach a device's :9202 server behind NAT, so the device pushes. Reused verbatim: the PSRAM ring and batch packer (serveBatched), the RECT / HEARTBEAT / AUDIO / AUDIO_CUT wire packets, the whole cast-viewer.html (decode, re-timing, xBR, audio, shield, keypad) and NET/RemoteKeys. The retired relay reused these too; what broke it was the transport around them - a hand-rolled overlapped HTTP client on the streamer task, an ingest Lambda, an API Gateway WebSocket per region, base64 text frames and a viewer bucket. This design keeps the streamer task on the ordinary HTTPClient + Signing::addSignedHeaders path that SimShare has run for months, and uses the dashboard's EXISTING WebSocket (the one that pushes new shots to open browsers).

Data path

device (cast task)  --POST /cast/ingest (batch, HMAC, plain http)-->  share.foogolf.com
      ^                                                                   | /cast/* -> foogolf-dashboard HttpApi
      |  {viewers, keys[], next_seq, stop, kf}                            v
      |                                                        cast_ingest Lambda
      |                                                           | post_to_connection {"type":"cast","d":base64}
      |                                                           v
      |                                            dashboard WebSocket (foogolf-dashboard-ws)
      |                                                           | connection rows with cast_device == D
      |                                                           v
      +--- cast-inputs FIFO <-- ws_default "key" <-- Support page (cast-viewer.html?cloud=1) keypad

Several devices at once (the UI question)

Each Support tab is its own WebSocket subscription to one device, so the support team can open a tab per device; the Devices page shows every device's state (requested / live) in one column. Leaving a Support page does NOT clear the flag by itself - the $disconnect Lambda clears it only when the last viewer of that device is gone, so a second person watching the same device keeps it streaming, and a tab that merely reloads reconnects, re-subscribes and re-arms within seconds. The explicit Stop button clears it for everyone. On the device side the session also ends itself after 10 minutes without any viewer.

5. Support cast vs. LAN cast - does one disable the other?

No. They are two sinks of ONE streaming session (SessionSink): one ring, one batch, delivered to every active sink, each started and stopped live from its own command (Controls > Screen casting for the LAN sink, the dashboard flag for the cloud sink); the session ends when neither is on. What you should expect while both run:

6. Cost of a support session

Roughly $0.06-$0.08 an hour per session with audio (4-5 POSTs a second while the screen changes: API Gateway $1/M + CloudFront requests + a short Lambda + WebSocket messages per viewer + data), less while the screen is static (300 ms idle polls held 500 ms are cheap), and about $0.004 an hour while the flag is set but nobody watches (one empty poll every 2 s). Sessions are explicit and end themselves, so there is no always-on cost. Borne by the supporter / coach, outside the per-device budget.

7. Verification

8. Open items