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:
- Your software has to be reachable from the connector. On a home network that means the same Wi-Fi and a known IP. Over the internet it means a bridge, because nobody should expose an unauthenticated port 921 to the world (more on that below).
- Port 921 is privileged on Linux and macOS. Either grant the capability (
setcap 'cap_net_bind_service=+ep' $(readlink -f $(which node))) or listen on a high port and tell the golfer to type that port into the connector. Swingman tries 921 and falls back to 9210. - GSPro serves one monitor at a time. Mirror that: accept one client, and when a second connects, drop the first. Connectors reconnect after a dropped Wi-Fi link, and the old half-dead socket must not block them.
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
TotalSpinis not guaranteed. The spec lets a device sendBackSpinandSideSpinwithoutTotalSpin. Our first schema requiredTotalSpin, which would have rejected those devices. Accept either and derive the missing one: total is the hypotenuse of back and side, and the spin axis isatan2(side, back).- Club data may be absent or estimated. Radar devices derive face and path; camera devices without a club sticker estimate them.
ContainsClubDatatells you whether the fields exist, not whether to believe them, so carry a per-device "measured or estimated" flag into whatever uses the numbers. - Units flip per session. Some apps send
Meterswhen the phone is set to metric. Convert on receipt and store one unit. - Keep-alive on both sides. Set TCP keep-alive and no-delay on the accepted socket. Phones sleep; you want to notice.
- Don't expose it to the internet. There is no authentication in the protocol. A cloud deployment needs a small bridge: a process on the golfer's network that accepts the connector on 921 and forwards shots over HTTPS with a per-user token. That is why swingman.co.uk doesn't accept live connections yet and the live path is self-hosted.
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.