Sim Connector Abstraction — GSPro + OpenGolfSim¶
ARCHIVED DOCUMENT
This is a historical design or implementation note, kept as a record of why the code is shaped the way it is. It describes the project as of the date in its filename and is not a guide to follow — commands, paths, and constants may no longer match the code. See the Archive index for current alternatives.
Date: 2026-06-13
Status: Plan — awaiting approval to start Phase 1
Branch: feat/sim-connectors (new, off main)
Supersedes: the stale feat/gspro-integration branch (unmergeable — ~40k lines diverged from main)
Goal¶
Stream OpenFlight shots into golf simulators through a protocol-agnostic connector layer. Ship GSPro (OpenConnectV1) and OpenGolfSim as the first two connectors, with a third sim reducible to a single new codec.py. The shot-detection pipeline is not modified — connectors hang off the existing fan-out point.
Decisions locked (2026-06-13)¶
- Salvage: port Coleman's
src/openflight/gspro/module + its 10 tests onto the new branch, then refactor behind the shared seam. Reuse the tested code; don't rewrite. - Abstraction depth: share both the TCP transport and the fallback/provenance resolver in a
sim/core. Codecs only serialize + parse. - Concurrency: support N simultaneous connectors.
config/sim.jsonlists targets;on_shot_detectedfans out to all enabled+connected. - UI events: generic
sim_status/sim_shot/sim_playercarrying atargetname. UI is greenfield on main (none of Coleman's GSPro UI is on main), so no migration cost.
Protocol comparison (the reason for a codec seam)¶
| GSPro OpenConnectV1 | OpenGolfSim | |
|---|---|---|
| Transport | TCP :921, JSON | TCP :3111, JSON |
| Shot frame | flat ShotPayload, PascalCase, DeviceID/APIversion/ShotDataOptions |
nested {type,unit,shot:{…}}, camelCase |
| Ball fields | Speed, HLA, VLA, CarryDistance | ballSpeed, horizontalLaunchAngle, verticalLaunchAngle |
| Spin fields | TotalSpin + BackSpin + SideSpin + SpinAxis | spinSpeed + spinAxis only |
| Carry | sent | not sent (sim computes it) |
| Heartbeat | yes, 5s | none documented |
| On connect | nothing | optional {type:"device","status":"ready"} |
| Inbound | code 201 Player{Handed,Club}; 200 ack; 5xx error |
{type:"player",…} (club name + two-letter id); shot-result events |
| Ack codes | 200 / 201 / 5xx | none documented |
Shared across both: a TCP client (connect/reconnect/backoff/recv-loop/brace-balanced JSON framing) and the Shot → fields fallback+provenance logic. Different per protocol: the final serialize step and the inbound parse. That boundary is the seam.
Target module layout¶
src/openflight/sim/
├── __init__.py # public: SimConnector, build_connectors, load_sim_config, ConnectionState
├── transport.py # TcpSimClient: socket lifecycle, framing, reconnect, OPTIONAL heartbeat (codec-driven)
├── resolver.py # resolve_shot(Shot, PlayerState) -> ResolvedShot (+ provenance). The fallback table, once.
├── types.py # ConnectionState, PlayerState, ResolvedShot, InboundEvent variants, club-map helpers
├── config.py # load_sim_config(): config/sim.json + CLI merge -> list[ConnectorConfig]
└── codec.py # Codec Protocol + build_connectors(configs) factory
src/openflight/gspro/
└── codec.py # GSProCodec: ResolvedShot -> OpenConnectV1 bytes; parse 200/201/5xx; heartbeat; club map
src/openflight/opengolfsim/
└── codec.py # OpenGolfSimCodec: ResolvedShot -> {type:shot,…}; parse player/shot; no heartbeat; device-ready; club map
Core interfaces (sim/types.py + sim/codec.py)¶
# types.py
class ConnectionState(Enum):
DISABLED, CONNECTING, CONNECTED, RECONNECT_BACKOFF, STOPPED
@dataclass
class ResolvedShot:
ball_speed_mph: float
vla: float
hla: float
total_spin_rpm: float
spin_axis_deg: float
back_spin_rpm: float
side_spin_rpm: float
carry_yards: float
club_speed_mph: Optional[float] # None => no club data
club_path_deg: float
club: ClubType
provenance: Dict[str, str] # logical field ("ball_speed","vla",…) -> "measured"|"estimated"
@dataclass
class PlayerUpdate: handed: Optional[str]; club: Optional[ClubType]
@dataclass
class ShotAck: shot_number: Optional[int]; ok: bool; message: str = ""
@dataclass
class SimError: message: str
InboundEvent = Union[PlayerUpdate, ShotAck, SimError]
@dataclass
class PlayerState: # one shared instance across connectors
handed: str = "RH"
club: ClubType = ClubType.DRIVER
shot_counter: int = 0
def next_shot_number(self) -> int: ...
# codec.py
class Codec(Protocol):
name: str # "gspro" | "opengolfsim"
def build_shot(self, r: ResolvedShot, p: PlayerState) -> bytes: ...
def parse_inbound(self, frame: bytes) -> list[InboundEvent]: ...
def heartbeat_bytes(self, p: PlayerState) -> Optional[bytes]: ... # None => no heartbeat thread
def on_connect_bytes(self) -> Optional[bytes]: ... # OGS device-ready; None for GSPro
def fields_for_target(self) -> list[str]: ... # which logical fields this sim emits (for UI badges)
TcpSimClient (transport.py) is Coleman's gspro/client.py with the GSPro-specific bits removed: it takes a Codec, runs the recv loop, calls codec.parse_inbound, dispatches each InboundEvent to on_inbound, and only spawns the heartbeat thread when codec.heartbeat_bytes(...) is non-None. The _find_json_end brace-balanced framing stays in transport (both protocols are JSON-over-TCP).
SimConnector = (codec, TcpSimClient) pair plus send_shot(resolved)/is_connected()/start()/stop(). build_connectors(configs) maps each ConnectorConfig.type to its codec class.
Resolver (sim/resolver.py) — the shared fallback table¶
Lifts Coleman's shot_builder.build() logic but emits a protocol-neutral ResolvedShot keyed on logical field names. Reuses _OPTIMAL_LAUNCH, SPIN_CONFIDENCE_HIGH (both on main). Rules unchanged:
| Logical field | measured source | estimated fallback |
|---|---|---|
| ball_speed | Shot.ball_speed_mph (else raise IncompleteShotError) |
— |
| vla | launch_angle_vertical |
_OPTIMAL_LAUNCH[club] |
| hla | launch_angle_horizontal |
0.0 |
| total_spin | spin_rpm if spin_confidence >= 0.7 |
per-club spin table (temp; replace w/ ballistics module later) |
| spin_axis | spin_axis_deg |
0.0 |
| back_spin / side_spin | total·cos(axis) / total·sin(axis) |
derived (estimated if either input estimated) |
| carry | estimated_carry_yards (already a model) |
derived |
| club_speed | club_speed_mph |
None (codec sets ContainsClubData=false / omits) |
| club_path | club_path_deg |
0.0 |
Codecs map logical→wire field names for provenance so the UI badge layer stays generic.
Config (sim/config.py)¶
config/sim.example.json (checked in; config/sim.json gitignored):
{
"connectors": [
{"type": "gspro", "enabled": true, "host": "127.0.0.1", "port": 921, "device_id": "OpenFlight", "units": "Yards", "heartbeat_interval_s": 5},
{"type": "opengolfsim", "enabled": false, "host": "127.0.0.1", "port": 3111, "units": "imperial"}
]
}
CLI (in start-kiosk.sh + server): --gspro host[:port] and --opengolfsim host[:port] enable+override the matching connector; --no-sim disables all. Precedence: --no-sim > per-sim flag > file > defaults. Missing file = all disabled.
Server wiring (server.py)¶
- Global
sim_connectors: list[SimConnector] = [], sharedsim_player_state = PlayerState(). main():cfgs = load_sim_config(args);sim_connectors = build_connectors(cfgs, on_status=…, on_inbound=…);start()each.- on_shot_detected(), after the existing
socketio.emit("shot", …):try: resolved = resolve_shot(shot, sim_player_state) except IncompleteShotError as e: socketio.emit("sim_shot_dropped", {"reason": str(e)}); return-ish for c in sim_connectors: if c.is_connected(): c.send_shot(resolved) # codec serializes per protocol socketio.emit("sim_shot", {"target": c.name, "payload": …, "provenance": …}) - Inbound (
on_inbound(target, event)):PlayerUpdate→ update sharedsim_player_state+monitor.set_club(...)+ emitsim_player {target,…}+club_changed.SimError→ emitsim_statuserror.ShotAck→ session log. - Status callback →
sim_status {target, state, host, port, attempt, next_retry_in_s, message}+ session log. - Shutdown handler stops every connector.
- Session logger: generic
log_sim_send/status/playercarryingtarget(replaces Coleman'slog_gspro_*).
UI (greenfield on main)¶
- One reusable
<SimStatusPill target=… state=… />, rendered once per connector in the status bar. Colors: green=connected, amber=reconnecting/error, gray=disabled. Driven bysim_statusevents keyed bytarget. - Per-shot "Sent to sim" section with
M/Eprovenance badges, driven bysim_shot(provenance is logical-field based, so the badge component is sim-agnostic;fields_for_targetdecides which rows show).
Phases (tracer-bullet vertical slices — each ends green)¶
- Phase 1 — Port + extract core, prove with GSPro. Branch off main.
git checkout origin/feat/gspro-integration -- src/openflight/gspro tests/test_gspro_*.py. Extractsim/transport.py+sim/resolver.py+sim/types.pyfromgspro/client.py+shot_builder.py+state.py; refactor GSPro intogspro/codec.pyagainst the core. Acceptance: all ported GSPro tests pass with identical wire output; the seam exists and carries one connector. (This is the tracer bullet — transport→resolver→codec works end-to-end before a second protocol exists.) - Phase 2 — Server registry + generic events. Replace any single-client wiring with
list[SimConnector];config/sim.jsonlist; genericsim_*WS events +log_sim_*. Acceptance: GSPro still works, now via the registry;serverwiring test covers fan-out to ≥1 connector. - Phase 3 — OpenGolfSim codec. Add
opengolfsim/codec.py(envelope shape, camelCase, no heartbeat, device-ready on connect, two-letter club map, inbound player parse), config type,--opengolfsimflag, tests. Acceptance: mock-TCP integration test confirms a shot reaches an OGS mock with correct JSON; both connectors can run concurrently. - Phase 4 — UI. Generic status pills + provenance badges from
sim_*. Acceptance: component tests; manual check both pills render. - Phase 5 — Docs + setup.
docs/sim-integration.md(per-sim setup + manual hardware test),config/sim.example.json, raspberry-pi-setup mention, start-kiosk flags.
Tests¶
- Reuse: Coleman's
test_gspro_messages/test_gspro_shot_builder/test_gspro_state/ framing / heartbeat / lifecycle tests, re-pointed at the new layout. - New: resolver fallback-table tests (every rule + provenance dict +
IncompleteShotError); transport tests against a shared mock-TCP-server harness (reconnect/backoff, brace framing across packet boundaries, heartbeat present-vs-absent, on-connect bytes); GSPro codec round-trip; OpenGolfSim codec round-trip + inbound player parse; server registry fan-out (2 connectors, one disconnected); config merge (list + CLI precedence,--no-sim).
Open dependencies / deferred¶
- Per-club spin table stays inline in the resolver (marked temporary), to be swapped for the ballistics spin model when that lands — same as Coleman's note.
- OpenGolfSim has no documented ack codes or heartbeat;
ShotAckparsing for OGS is best-effort andheartbeat_bytesreturns None. Revisit if their API gains them. - Putting (
PT/ OGS equivalent) out of scope; logged + mapped toUNKNOWN. - Settings-panel UI deferred (config-file only in v1), matching Coleman's original scope.