Swingman
Protocol

Blog ·

Implementing a GSPro Open Connect v1 server

Most consumer launch monitors can send shots to GSPro over a simple TCP protocol. Here is what it takes to be on the receiving end: framing, the message shape, acknowledgements, heartbeats and the gotchas.

If you want launch-monitor data in your own software, you do not need a vendor SDK. Nearly every consumer monitor ships a connector for GSPro, and GSPro's side of that conversation is a documented, open protocol: Open Connect v1. Pretend to be GSPro and the shots come to you.

Swingman's server does exactly that. This post is what we learned building it, so you can build yours in an afternoon.

Who connects to whom

The direction surprises people. The launch monitor's connector is the TCP client. The simulator is the TCP server, listening on port 921. So your software opens a listening socket and waits; the golfer types your machine's IP address into the connector app, which connects and starts pushing JSON.

Consequences:

The message

One JSON object per shot, and also one per heartbeat or status change:

{
  "DeviceID": "Square Golf",
  "Units": "Yards",
  "ShotNumber": 14,
  "APIversion": "1",
  "BallData": {
    "Speed": 142.3,
    "SpinAxis": 4.1,
    "TotalSpin": 2650,
    "BackSpin": 2643,
    "SideSpin": 190,
    "HLA": 1.2,
    "VLA": 12.8,
    "CarryDistance": 241.5
  },
  "ClubData": {
    "Speed": 97.0,
    "AngleOfAttack": -1.1,
    "FaceToTarget": 2.3,
    "Lie": 0,
    "Loft": 14.2,
    "Path": -3.1,
    "SpeedAtImpact": 97.0,
    "VerticalFaceImpact": 2,
    "HorizontalFaceImpact": -4,
    "ClosureRate": 0
  },
  "ShotDataOptions": {
    "ContainsBallData": true,
    "ContainsClubData": true,
    "LaunchMonitorIsReady": true,
    "LaunchMonitorBallDetected": true,
    "IsHeartBeat": false
  }
}

Sign conventions are for a right-handed golfer: positive is right, open, in-to-out, up, toe and high. Flip the horizontal ones for a left-hander before you reason about them. Speeds are mph, angles degrees, spin rpm, face impact millimetres, distances in whatever Units says.

ShotDataOptions is the part people skip and then regret. ContainsBallData: false with IsHeartBeat: true is a keep-alive; LaunchMonitorIsReady and LaunchMonitorBallDetected flip as the golfer sets up a ball. Treat only ContainsBallData: true with a BallData object as a shot. Everything else is status, worth showing in your UI ("ball detected", "monitor not ready") and nothing more.

Framing: there is no delimiter

Open Connect writes one JSON object per send(), with no newline or length prefix between them. TCP does not care about your objects: a slow network will deliver half an object in one read() and the rest with the next object glued on. If you JSON.parse each chunk you will drop shots under exactly the conditions where you can't reproduce it.

The fix is a small state machine that scans the stream, tracks brace depth while respecting strings and escape characters, and emits each complete top-level object. Swingman's is about forty lines. Two extra rules keep it safe: drop any bytes that arrive at depth zero that aren't { (whitespace, stray garbage), and reset if the buffer grows past a sane limit (we use 64 KB) so a misbehaving client can't make you hold memory forever.

Acknowledging

Reply to every message, including heartbeats:

{ "Code": 200, "Message": "OK" }

Or, when you want to tell the connector about the golfer, reply with 201 and a Player object instead:

{
  "Code": 201,
  "Message": "GSPro Player Information",
  "Player": { "Handed": "RH", "Club": "7I", "DistanceToTarget": 150 }
}

Connectors use Player to set handedness and the selected club on the device's display, and GSPro pushes a fresh 201 unsolicited whenever the club changes. Do the same when your user taps a club. Send 501 with a message for anything you could not parse or validate, and keep the connection open: a malformed heartbeat is not a reason to disconnect a golfer mid-session.

Validate, then log everything raw

Validate each object against a schema before trusting it. Swingman uses a zod schema and rejects anything that doesn't fit; the connector gets a 501 and the raw text goes to the log so the alias or the field can be added later. Separately, append every raw message to a JSONL file. When the first real device of a new brand connects, that file is how you find out what it actually sends, and it lets you replay a whole session against new code without the hardware (pnpm mock --replay data/raw/<file>.jsonl).

Gotchas we hit

A mock device is worth building first

Before any hardware arrived, Swingman had a mock connector: a TCP client with a toy ball-flight model and scenarios ("slicer", "hooker", "steep driver") that sends realistic Open Connect messages at a chosen interval. It is how the coach was built and tested, how the framing was proven against coalesced and split packets, and how every screen was rehearsed. If you build a server, build the client too. It costs an evening and saves every one after it.

Where this leaves you

Any monitor with a GSPro connector can now talk to your software: Square Golf, FlightScope Mevo via the FS Golf app's Open API, Rapsodo MLM2PRO with Premium, Uneekor and Bushnell with their plans, and Garmin's radars through one of the community bridge apps. You have a protocol, not a partnership, and for most practice software that is enough. Swingman's implementation lives in packages/server/src/ingest/openconnect/ and packages/shared/src/protocol/, and the works-with page lists the devices and how each one gets in.

Questions or a correction? Get in touch.