Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
6dee88d
build(deps-dev): bump eslint-plugin-react-hooks in /ui
dependabot[bot] May 3, 2026
2064d26
build(deps-dev): bump globals from 17.5.0 to 17.6.0 in /ui
dependabot[bot] May 3, 2026
77f849e
feat: add physics-based ball flight simulator
JedS Apr 12, 2026
7b31c1d
Update ballistics.py
JedS Apr 14, 2026
9a099bd
Share SPIN_CONFIDENCE_HIGH between launch_monitor and ballistics
JedS Apr 14, 2026
0b061f4
Use ballistics simulator for shot carry; table is now fallback only
JedS Apr 25, 2026
19c296d
Merge branch 'jewbetcha:main' into feat/ballistics-physics-model
JedS Jun 3, 2026
660dec6
Add ballistics validation + Cd/Cl coefficient sweep tooling
JedS Jun 4, 2026
24124cb
Add --no-ballistics toggle to fall back to legacy table carry
JedS Jun 4, 2026
2ff01c8
fix(ops): refresh clock sync per sound trigger
Jun 7, 2026
be10ab7
Merge pull request #100 from johnpacino/fix/kld7-per-shot-ops-sync
jewbetcha Jun 7, 2026
7fe50bb
fix: install kld7 driver by default so clean installs don't fail
aadnesd Jun 8, 2026
3ee4537
Merge branch 'main' into dependabot/npm_and_yarn/ui/globals-17.6.0
jewbetcha Jun 8, 2026
383ad8e
Merge pull request #102 from aadnesd/fix/kld7-default-install
jewbetcha Jun 8, 2026
be1360e
Merge branch 'main' into dependabot/npm_and_yarn/ui/globals-17.6.0
jewbetcha Jun 8, 2026
f27bf4e
Merge branch 'main' into feat/ballistics-physics-model
jewbetcha Jun 9, 2026
69734ac
Merge pull request #79 from jewbetcha/dependabot/npm_and_yarn/ui/glob…
jewbetcha Jun 9, 2026
051eb26
Merge branch 'main' into dependabot/npm_and_yarn/ui/eslint-plugin-rea…
jewbetcha Jun 9, 2026
338080f
Merge pull request #77 from jewbetcha/dependabot/npm_and_yarn/ui/esli…
jewbetcha Jun 9, 2026
d36f9eb
Merge branch 'main' into feat/ballistics-physics-model
jewbetcha Jun 9, 2026
b7a851c
fix(kiosk): launch server via uv run so a moved venv self-heals
pablitoarango Jun 9, 2026
be04580
docs(ui): replace Vite template README with OpenFlight UI docs
pablitoarango Jun 9, 2026
00c4b8b
Merge branch 'main' into docs/ui-readme
jewbetcha Jun 9, 2026
7f51972
Merge pull request #103 from pablitoarango/docs/ui-readme
jewbetcha Jun 9, 2026
8199630
Merge branch 'main' into fix/start-kiosk-uv-env
pablitoarango Jun 9, 2026
ec5f027
Merge pull request #104 from pablitoarango/fix/start-kiosk-uv-env
jewbetcha Jun 9, 2026
ec4964e
Default ballistics off; rename flag to --ballistics opt-in
JedS Jun 9, 2026
b51dc42
Merge branch 'feat/ballistics-physics-model' of https://github.com/Je…
JedS Jun 9, 2026
f63b523
Merge remote-tracking branch 'origin/main' into feat/ballistics-physi…
JedS Jun 9, 2026
d8f1baf
[cr] add script to test direct interface with kld7
jewbetcha Jun 9, 2026
56451dd
Merge branch 'main' into feat/ballistics-physics-model
jewbetcha Jun 9, 2026
7581401
Merge pull request #61 from JedS/feat/ballistics-physics-model
jewbetcha Jun 9, 2026
ed4e888
[cr] some code cleanup
jewbetcha Jun 11, 2026
39803e5
[cr] add kld7 dc-alias blind zone flag and sub-frame fringe instrumen…
jewbetcha Jun 11, 2026
4c265d8
[cr] fresh pass at spin dedection
jewbetcha Jun 11, 2026
ed6f37a
[cr] updates to the interactive setup script
jewbetcha Jun 11, 2026
f995346
[cr] update outdated readme
jewbetcha Jun 11, 2026
989d282
[cr] docs for spin replay analysis
jewbetcha Jun 11, 2026
f9ec7c1
[cr] give unique UUID for each session
jewbetcha Jun 11, 2026
30cc49a
fix(ops): don't truncate buffer dump when trigger fires near timeout
Jun 13, 2026
0290668
Merge pull request #107 from johnpacino/fix/ops-trigger-timeout-trunc…
jewbetcha Jun 13, 2026
2862a39
Bump @types/node from 25.6.0 to 25.9.3 in /ui
dependabot[bot] Jun 14, 2026
f5008ce
[cr] first pass at cloud uploader service
jewbetcha Jun 15, 2026
5efa95b
[cr] update sync docs
jewbetcha Jun 15, 2026
fd9760e
[cr] fix uploads of big files with raw ADC
jewbetcha Jun 15, 2026
46f022e
[cr] add retry to uploader
jewbetcha Jun 15, 2026
9f4929a
Merge pull request #114 from jewbetcha/jewbetcha/cloud-sync
jewbetcha Jun 15, 2026
c3a2a25
Merge branch 'main' into dependabot/npm_and_yarn/ui/types/node-25.9.3
jewbetcha Jun 15, 2026
5a4d67f
Merge pull request #112 from jewbetcha/dependabot/npm_and_yarn/ui/typ…
jewbetcha Jun 15, 2026
658a0f3
feat: ball-speed cosine correction + opt-in calculated spin
Jun 16, 2026
51d6206
feat(ui): show spin provenance (estimated/radar) on the shot display
Jun 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1780860058366}
92 changes: 68 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ OpenFlight is an open-source golf launch monitor that uses Doppler radar to meas

### What It Measures

- **Ball Speed**: 30-220 mph range with ±0.5% accuracy (OPS243-A)
- **Ball Speed**: 35-200 mph range with ±0.5% accuracy (OPS243-A)
- **Club Speed**: Detected from pre-impact readings (OPS243-A)
- **Smash Factor**: Ball speed / club speed ratio
- **Launch Angle**: Vertical launch measured by K-LD7 angle radar
- **Club Path**: Horizontal aim direction measured by second K-LD7
- **Spin Rate**: Via rolling buffer I/Q analysis (~50-60% detection rate)
- **Spin Rate**: Via rolling buffer I/Q analysis (the hardest radar measurement — see [Limitations](#limitations))
- **Carry Distance**: Computed from ball speed, launch angle, and spin

### Hardware at a Glance
Expand All @@ -33,7 +33,7 @@ OpenFlight is an open-source golf launch monitor that uses Doppler radar to meas
| OPS243-A Radar | Ball speed, club speed, spin | $249 |
| Raspberry Pi 5 | Runs everything | $60 |
| 7" Touchscreen | Shows shot data | $46 |
| SparkFun SEN-14262 | Sound trigger for spin detection | $18 |
| SparkFun SEN-14262 | Impact sound trigger for shot capture | $18 |
| K-LD7 (×2) + FTDI adapters | Launch angle + club path | $140 |
| Power supply + accessories | | $27 |
| **Total** | | **~$540** |
Expand All @@ -52,7 +52,19 @@ Follow the **[Sound Trigger Wiring Guide](docs/sound-trigger-wiring.md)** to con

### 3. Set up the Pi

Follow the **[Raspberry Pi Setup Guide](docs/raspberry-pi-setup.md)** for OS installation, software setup, radar configuration, and auto-start.
Flash Raspberry Pi OS (64-bit), plug in the radars, then run the interactive setup:

```bash
git clone https://github.com/jewbetcha/openflight.git
cd openflight
./scripts/setup/setup.sh
```

The script installs everything and walks you through the one-time hardware
configuration (radar flash setup, K-LD7 device naming, auto-start) with
prompts — no manual config editing needed. It's safe to re-run any time.
See the **[Raspberry Pi Setup Guide](docs/raspberry-pi-setup.md)** for
details and troubleshooting.

### 4. Hit balls

Expand All @@ -69,6 +81,24 @@ scripts/start-kiosk.sh --mock

Then open http://localhost:8080 or use the touchscreen.

### 5. Sync to the cloud (optional)

OpenFlight can push your sessions to the **FlightWeb** cloud so you can review
shots from any device. It's opt-in, and **raw radar data never leaves your
Pi** — only shot results and session metadata are uploaded (verify with
`openflight-cloud push --dry-run`).

`setup.sh` offers to enable this and link your Pi. To do it by hand:

```bash
openflight-cloud link # pair this Pi (enter a short code in your browser)
openflight-cloud status # linked? queued? parked?
```

Once linked, sessions sync automatically (on session end and via a ~10-minute
timer that heals wifi outages). See the **[Cloud Sync Guide](docs/cloud-sync.md)**
for details.

### TV Display Mode

OpenFlight also serves a fullscreen-friendly browser display for tablets, TV browsers, or a Chrome tab cast to Chromecast.
Expand All @@ -86,9 +116,10 @@ This is browser/tab casting only. OpenFlight does not include native Cast SDK su

```
┌─────────────┐ USB/Serial ┌─────────────┐ Callback ┌─────────────┐ WebSocket ┌─────────────┐
│ OPS243-A │ ───────────▶ │ Launch │ ──────────▶ │ Flask │ ──────────▶ │ React │
│ Radar │ Speed data │ Monitor │ on_shot() │ Server │ "shot" │ UI │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│ OPS243-A │ ───────────▶ │ Rolling │ ──────────▶ │ Flask │ ──────────▶ │ React │
│ Radar │ I/Q buffer │ Buffer │ on_shot() │ Server │ "shot" │ UI │
└─────────────┘ │ Monitor │ └─────────────┘ └─────────────┘
└─────────────┘
┌─────────────┐ USB/Serial │
│ K-LD7 (×2) │ ──────────────────── angle data ──────────────┘
Expand Down Expand Up @@ -123,32 +154,41 @@ The K-LD7 modules are positioned near the OPS243-A, one mounted vertically (laun

### Radar Settings for Golf

| Setting | Value | Why |
| ----------- | -------- | --------------------------- |
| Sample Rate | 20 kHz | Supports up to ~139 mph |
| Buffer Size | 512 | Faster updates (~10-15 Hz) |
| Min Speed | 10 mph | Filter slow movements |
| Direction | Outbound | Ball moving away from radar |
| Power | Max (0) | Best detection range |
| Setting | Value | Why |
| -------------- | ---------------------- | -------------------------------------------- |
| Mode | Rolling buffer | Raw I/Q capture for spin + precise speeds |
| Sample Rate | 30 ksps | Supports up to ~208 mph ball speed |
| Capture | 4096 I/Q samples | ~136 ms around impact |
| Trigger | Sound (SEN-14262) | ~10 µs hardware latency via HOST_INT |
| Min Ball Speed | 35 mph | Filter club waggle and slow movements |
| DC Mask | ~15 mph exclusion zone | Reject body movement and environmental noise |

These are applied automatically — the one-time flash configuration is handled
by the setup script.

### Python API

```python
from openflight import LaunchMonitor
from openflight.rolling_buffer import RollingBufferMonitor

monitor = RollingBufferMonitor() # auto-detects the OPS243-A
monitor.connect()
monitor.start()

with LaunchMonitor() as monitor:
print("Swing when ready...")
shot = monitor.wait_for_shot(timeout=60)
print("Swing when ready...")
shot = monitor.wait_for_shot(timeout=60)
if shot:
print(f"Ball Speed: {shot.ball_speed_mph:.1f} mph")
print(f"Est. Carry: {shot.estimated_carry_yards:.0f} yards")

if shot:
print(f"Ball Speed: {shot.ball_speed_mph:.1f} mph")
print(f"Est. Carry: {shot.estimated_carry_yards:.0f} yards")
monitor.stop()
monitor.disconnect()
```

## Limitations

- **Cosine error**: If ball doesn't travel directly toward/away from radar, measured speed will be slightly lower than actual
- **Spin detection**: ~50-60% reliable — depends on signal quality and trigger timing
- **Spin detection**: The hardest radar measurement, especially indoors — the usable signal window ends when the ball hits the net, and short windows can't resolve low spin (commercial radar units have the same constraint and fall back to estimated spin indoors). Low driver-band readings (≤~3100 RPM) are reported at reduced confidence. When spin isn't measured, carry falls back to club-typical spin values. Improving this is an active focus.
- **K-LD7 speed aliasing**: The K-LD7 max speed is 62 mph, so it's used only for angle/distance, not speed

### Ball Markings
Expand Down Expand Up @@ -183,7 +223,9 @@ openflight/
│ ├── server.py # Flask server, K-LD7 correlation, carry
│ ├── session_logger.py # JSONL session logging
│ ├── kld7/ # K-LD7 angle radar
│ │ ├── tracker.py # Ring buffer, ball/club detection
│ │ ├── radc.py # FFT, phase interferometry, angle extraction
│ │ ├── tracker.py # Ring buffer, shot correlation
│ │ ├── geometry.py # Launch-angle trajectory fitting
│ │ └── types.py # Data types
│ └── rolling_buffer/ # Spin rate detection
│ ├── monitor.py # Rolling buffer monitor
Expand All @@ -202,7 +244,7 @@ Contributions welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.

Areas of interest:

- **Better spin detection**: Increase reliability beyond 50-60%
- **Better spin detection**: A dechirped Doppler-sideband estimator is in development (`scripts/analysis/replay_spin_dechirp.py`) — help validating it against launch-monitor truth data is especially welcome
- **K-LD7 signal processing**: Improve ball detection from sparse radar frames
- **Mobile app**: Bluetooth connection to phone

Expand All @@ -217,7 +259,9 @@ uv run pytest tests/ -v
- **[Parts List](docs/PARTS.md)** — What to buy
- **[Sound Trigger Wiring](docs/sound-trigger-wiring.md)** — How to wire the sound trigger
- **[Raspberry Pi Setup](docs/raspberry-pi-setup.md)** — Full setup guide
- **[Cloud Sync](docs/cloud-sync.md)** — Push filtered sessions to FlightWeb
- **[Rolling Buffer & Spin Detection](docs/rolling_buffer_spin_detection.md)** — Spin measurement details
- **[Dechirped-Sideband Spin Replay](docs/spin-dechirp-replay.md)** — Next-gen spin estimator test bench
- **[K-LD7 Ball Detection Theory](docs/kld7-ball-detection-theory.md)** — How angle detection works
- **[K-LD7 Session Review](docs/kld7-session-review.md)** — Offline review workflow for session JSONL files
- **[Observability & Log Shipping](docs/observability.md)** — Ship logs to Grafana Cloud
Expand Down
178 changes: 178 additions & 0 deletions docs/cloud-sync-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
# OpenFlight Cloud Sync — Client-Side Design (Proposal)

Status: **draft / not implemented**. Design for the two pieces of the planned
cloud service that live in this (public) repo: the ingest API contract the Pi
speaks, and the uploader that speaks it. The service itself lives in a
separate private repo; this document is the interface between them.

Design priorities, in order:

1. **The contract is forever.** Fielded Pis update rarely. The wire format
must tolerate old clients indefinitely and evolve additively.
2. **Never upload raw ADC/I-Q.** Filtered session summaries only — keeps
storage costs sane, uploads fast on bad wifi, and is the
privacy-friendly default.
3. **No user babysitting.** Sessions upload themselves when connectivity
exists; "no wifi at the range" is a non-event, not a manual step.

---

## 1. Ingest API contract (v1)

All endpoints under `https://<cloud-host>/v1/`, TLS only. Authentication is
an opaque per-device bearer token (`of_device_...` prefix) — revocable from
the web app, scoped to one device.

### Device linking (how a Pi gets its token)

Simplified RFC 8628 device-code flow — the user never copies a long token
onto the Pi:

```
Pi Cloud
── ─────
POST /v1/device-link/start
{device_name, client_version} ─────▶
◀───── {link_code: "ABCD-1234",
poll_token, interval_s, expires_s}

(Pi prints: "Go to cloud.openflight.example/link
and enter code ABCD-1234")

POST /v1/device-link/poll
{poll_token} (repeat) ─────▶
◀───── {status: "pending"}
◀───── {status: "linked",
device_token, device_id}
```

The user enters the code on the website while signed in. Token is stored at
`~/.config/openflight/cloud.json`, mode `0600`, never logged.

### Session upload

```
PUT /v1/sessions/{session_id}
Authorization: Bearer <device_token>
Content-Type: application/x-ndjson
Content-Encoding: gzip

<filtered session JSONL, gzipped>
```

- **`session_id` is the `session_uuid` embedded in the `session_start`
entry** (a UUID4 written at session creation since format_version 1 /
app 0.2.0 — survives file renames and copies). For older sessions that
predate the field, the uploader falls back to a deterministic UUIDv5 of
`(device_id, session filename)`. Either way the auto-push and the manual
push script can both submit the same session and the server dedupes for
free; PUT semantics make retries safe.
- The client prepends one manifest line to the body:

```json
{"type": "upload_manifest", "format_version": 1, "client_version": "0.2.0",
"device_id": "...", "filtered": true, "kept_entry_types": ["session_start", ...]}
```

**Responses:**

| code | meaning | client behavior |
|---|---|---|
| 201 | accepted (`{session_id, shot_count}`) | mark pushed |
| 200 | duplicate — already stored | mark pushed |
| 401 | token invalid/revoked | stop, flag "needs re-link" |
| 402 | quota/entitlement exceeded | park, retry daily |
| 413 | body too large (cap ~20 MB gzipped) | log error, park file |
| 422 | unparseable (`{reason}`) | log error, park file |
| 429 | rate limited (`Retry-After`) | back off |
| 5xx | server trouble | retry with backoff |

`GET /v1/health` → 200, used by the uploader to short-circuit when offline.

### Evolution rules

- The server must **accept and store unknown entry types** (skip parsing,
don't reject) — old and new clients coexist for years.
- Additive changes only within `/v1`; breaking changes get `/v2` and `/v1`
keeps working.

---

## 2. Client-side filtering (the raw-ADC strip)

Filtering uses an **allowlist**, not a blocklist — a future heavy entry type
added to the session logger can never leak to the cloud by accident:

```
keep: session_start, session_end, shot_detected, trigger_event, session_error
drop: rolling_buffer_capture, iq_blocks, iq_reading, reading_accepted,
and anything not on the keep list
guard: any kept line > 32 KB is dropped and counted (belt and suspenders)
```

`shot_detected` carries everything the insights product needs (speeds, spin
+ quality/confidence, angles, carry, K-LD7 diagnostics). `trigger_event` and
`session_error` are small and power reliability insights. Raw I/Q and RADC
stay on the Pi where they belong — they remain available locally for the
offline analysis workflows.

A typical filtered session is **tens of KB gzipped** vs tens of MB raw.

---

## 3. The uploader (spool-and-retry)

Lives in the public repo. Three entry points, one mechanism:

```
openflight-cloud link # device-link flow (one time)
openflight-cloud push [--dry-run] # filter + upload anything unpushed
openflight-cloud status # linked? queued? parked? last error?
```

**Mechanism:**

- The session directory itself is the queue. A session counts as "pushed"
when a sidecar marker (`<session>.jsonl.pushed`) exists — originals are
never moved or modified, state survives crashes, and no database is
involved.
- A **systemd timer** (every ~10 min) runs `push`; the server process also
fires a non-blocking `push` on session end. The timer makes wifi outages
self-healing; the hook makes the happy path fast. Neither can ever delay
shot processing.
- Per-file attempt counter (in the sidecar); after ~20 failures the file is
parked (`.parked`) and reported by `status` instead of retried forever.
- `--dry-run` prints exactly which entry lines would upload — the privacy
answer to "what are you sending?"

**Config** (`~/.config/openflight/cloud.json`):

```json
{"endpoint": "https://cloud.openflight.example",
"device_token": "of_device_...", "device_id": "...", "enabled": true}
```

Uploading is **opt-in**: nothing leaves the Pi until the user runs
`openflight-cloud link`. Later, the interactive `setup.sh` can offer linking
as an optional step.

---

## 4. Server-side checklist (private repo, for reference)

Not designed here, but the contract above implies: the four endpoints;
dedupe on `session_id`; entitlement check at ingest (quota → 402);
blob → object storage; parse kept entries → per-shot rows in Postgres;
device management UI (list/revoke); link-code UI.

## Open questions

- Tier gating: does the free tier get full history or last-N sessions?
(Affects only server; contract unchanged.)
- Should `iq_reading` summaries (SNR stats, no raw data) join the allowlist
later for radar-health insights? Cheap to add — allowlist makes it an
explicit decision.
- AGPL hygiene: the uploader (public repo) is AGPL like everything here;
the private service must not import code from this repo unless that code
is dual-licensed or contributor-cleared. Keep the boundary at the wire
contract.
Loading