The Garmin Approach R10 over Bluetooth - the protocol, as a driver
Status: describes the published FooGolf driver DPF2L version 9
(2026-10-09; version 6 of the same day - the API v2 manifest form and a
status text with every state - plus the diagnostics of section 9 (v7), the
status-from-state rule of section 7 (v8) and the self-recovery ladder of
section 8 (v9)), the handshake that has armed a real R10 on
the bench and delivered its shots into GSPro. This document is written so that a driver
author can reproduce that driver from it alone, against the API in
monitor-drivers.md, without reading the driver’s source. Byte layouts are
exact; where a choice was made because the unit refused the alternative, the
alternative is named so nobody tries it again.
Everything is little-endian unless stated. Offsets are 0-based in byte layouts and 1-based where Lua string positions are meant (the text says which). “Frame” means one GFDI frame; “chunk” means one BLE write or notification.
Sources beyond the bench: Garmin publishes nothing. The protocol was
reverse-engineered by the tenover / 10over project (Rust; its WIRE.md
and SEQUENCE.md are the clearest write-up of the framing) and by the
springbok MLM2PRO-GSPro-Connector project (Python; its r10.proto names
the protobuf fields, and its handshake is the one this driver copies). The
Garmin Golf phone app is the only other working client.
1. The monitor
The Approach R10 is a radar launch monitor. It has one button and one LED. It pairs with one host at a time and remembers it. The LED tells you what it thinks:
| LED | meaning |
|---|---|
| solid blue | pairing mode (hold the button until it goes solid); the only time it accepts a new bond |
| flashing blue | connected at the radio level, “reconnecting to the previous phone”: no application session yet |
| flashing green | armed and waiting for a swing - the goal |
| off | asleep or off; a short press of the button wakes it |
Battery level is on the standard Battery Service (180F / 2A19); the
device reads it itself.
2. Bluetooth: service, channels, pairing, chunking
foogolf.monitor{
name = "Garmin R10",
version = 5,
author = "support@foogolf.com",
about = "Garmin Approach R10 launch monitor over Bluetooth: full-swing shots into GSPro during a sim session.",
match = { { manufacturer = 0x0087, prefix = "\x26\x0e" }, -- Garmin's company id; the payload starts with
-- product 3622 = 0x0E26 little-endian
{ name_prefix = "Approach R10" } },
service = "6A4E2800-667B-11E3-949A-0800200C9A66", -- Garmin "MultiLink"
chars = { reg = { "6A4E2810-667B-11E3-949A-0800200C9A66", "6A4E2812-667B-11E3-949A-0800200C9A66" },
data = { "6A4E2820-667B-11E3-949A-0800200C9A66", "6A4E2822-667B-11E3-949A-0800200C9A66" } },
subscribe = { "reg" },
bond = true,
pairing_hint = "Hold the R10's power button until its LED is solid blue",
wake_hint = "Off? Press the R10's power button",
}
- Advertising. Manufacturer-specific data with company id
0x0087(Garmin) followed by product3622(0x0E26) little-endian - the two bytes26 0E, hence theprefixrule - and the nameApproach R10. A bonded R10 may advertise directed at its bonded host with no payload at all; the device reconnects by address for that reason, and your manifest’s rules are used only while pairing. - The MultiLink service
6A4E2800-...carries several channels, each a pair of characteristics: one that notifies and takes the REGISTER command (2810, alternatively2812), and one that takes data writes (2820, alternatively2822). The two PC clients each worked on a different pair; the manifest lists both and the device uses the first pair that exists. Your code writes by key (reg,data) and never learns which pair is live. - Bonding is required: the unit refuses the GFDI session unencrypted.
Just Works, no PIN. The R10 accepts a new bond only in pairing mode
(solid blue LED); otherwise the pairing attempt is refused or the unit
drops the link mid-pairing. This is why
pairing_hintsays what it says. - MTU and chunking. Whatever MTU is negotiated, the R10 is written in
20-byte chunks: one handle byte (section 3) followed by up to 19
bytes of wire data, as a write without response on
data. Notifications arrive onreg, each carrying the handle byte first, and a frame routinely spans several notifications. - Writes. REGISTER goes to
regwith response ("rsp"); everything afterwards goes todatawithout response ("norsp"). Those are preferences; the device uses what each characteristic supports.
3. MultiLink: REGISTER and the handle byte
MultiLink multiplexes services over the one channel. Before anything else the host registers for service 1 (GFDI) and receives a one-byte handle that then prefixes every chunk in both directions.
REGISTER (13 bytes, written to reg, no handle byte):
00 00 command 0 = register
01 00 00 00 00 00 00 00 transaction id, u64 = 1
01 00 service id, u16 = 1 (GFDI)
00
As a Lua string: "\0\0\1\0\0\0\0\0\0\0\1\0\0".
Register response (a notification on reg, at least 15 bytes):
| offset | size | field |
|---|---|---|
| 0 | 1 | 00 |
| 1 | 1 | 01 = register response |
| 2 | 8 | transaction id |
| 10 | 2 | service id |
| 12 | 1 | status: 0 = ok, anything else = refused |
| 13 | 1 | handle |
| 14 | 2 | flags |
A non-zero status is a protocol error (“R10 refused the link”): report
"error" and disconnect. Any other notification that arrives while you are
waiting for this response is early GFDI noise; drop it.
From here on, every notification’s first byte is a handle; drop any notification whose handle is not yours (another MultiLink channel) and strip the byte before feeding the rest to the frame reassembler. Every chunk you write starts with your handle byte.
The nudge. Immediately after the register response the host writes one
chunk consisting of the handle byte followed by a single 00 byte to
data. Springbok calls this “continue handshake”; the unit expects it.
4. GFDI framing: COBS, the frame, the CRC
GFDI (Garmin Fitness Device Interface) frames travel as a byte stream.
Delimiting. Each frame is sent as 00 <COBS-encoded frame> 00. On the
wire the closing 00 of one frame is also the opening 00 of the next, so
the reassembler is: find a 00; everything before it is noise; find the
next 00; the bytes between are one COBS-encoded frame (an empty run is
ignored); keep the second 00 as the opener for the following frame. Cap
the reassembly buffer at 1024 bytes and clear it on overflow.
COBS (Consistent Overhead Byte Stuffing, Cheshire and Baker), without
delimiters inside the encoding. Encoding: split the input at zero bytes;
each run of non-zero bytes becomes [len+1] <run>; a run of 254 or more
non-zero bytes is emitted as FF <254 bytes> and continues; the input’s
trailing zero (if the input ends in a zero) gets its own 01 group. One
edge case must match the unit’s decoder: a FF block that ends exactly at
the end of the input opens no further group. Decoding is the inverse; a
code byte of 00, a block that runs past the end, or a block containing a
zero is a malformed frame (drop it, count it as a protocol error, carry
on). Reject a decoded frame longer than 512 bytes.
The frame, after COBS decoding:
| offset | size | field |
|---|---|---|
| 0 | 2 | len: the whole frame’s length in bytes including this field and the CRC |
| 2 | 2 or 2 | the header, in one of two forms (below) |
| 4 | len - 6 | payload |
| len - 2 | 2 | CRC-16/ARC over bytes 0 .. len-3 |
The minimum frame is 6 bytes. A frame whose len does not equal its actual
length, or whose CRC does not match, is dropped as a CRC error.
Two header forms. The standard header is the message type as a
u16. The compressed header (used by the unit for 5043 and 5044 frames
once it has announced protocol version 150 or later, which every current
R10 does) is two bytes: type - 5000 as a u8, then transaction_id | 0x80 as a u8. Tell them apart by the high bit of the fourth byte of the
frame (offset 3): set means compressed. Keep the transaction id (the low
seven bits) because the acknowledgement must echo it. Frames the host sends
use the standard header, except acknowledgements of compressed frames.
CRC-16/ARC: polynomial 0x8005 reflected (0xA001), initial value 0,
no final XOR, reflected input and output. Check value: crc16("123456789") = 0xBB3D. Build a 256-entry table at load time; 32-bit Lua integers hold
it comfortably.
Building a frame for sending: pack("<I2I2", 6 + #payload, msgType) .. payload, then append pack("<I2", crc16(body)); the compressed form is
pack("<I2BB", 6 + #payload, msgType - 5000, txn | 0x80) .. payload plus
the CRC. Wrap in 00 .. cobs .. 00, then chunk with the handle byte.
5. Message types and acknowledgements
| type | name | direction | what the host does |
|---|---|---|---|
| 5000 | ACK | both | the only reply to any other frame |
| 5011 | FIT definition | unit to host | ACK with status 0 and one extra 00 byte; ignore the contents |
| 5012 | FIT data | unit to host | same |
| 5024 | device information | unit to host | parse for the log (section 5.1); ACK with status 0 and no extra bytes |
| 5043 | protobuf request | both | ACK (section 5.2), then decode the protobuf |
| 5044 | protobuf response | unit to host | ACK (section 5.2), then decode the protobuf |
| 5050 | configuration / capabilities | unit to host | ACK with status 0 and no extra bytes |
| other | unit to host | ACK with status 2 (“unknown type”) so the unit moves on |
Every frame the unit sends must be acknowledged, and acknowledged in a form the unit accepts, or it retransmits the frame about every 8 s, three times, and never considers the session established (the LED stays flashing blue). Acknowledge before you act on the frame’s contents.
ACK payload: orig_type u16 | status u8 | extra bytes. Status 0 = OK,
2 = unknown type.
- For 5024 and 5050: no extra bytes. (The
tenoverwrite-up answers 5024 with the host’s own device information and follows 5050 with a host 5050 announcing a “SwingSensor” capability bit. Do not. The bench unit completed that handshake and then sat in state ERROR or retransmitting; the Garmin app and springbok send neither, and only their order has ever armed the unit.) - For 5043 and 5044: extra bytes = the first two bytes of the frame’s
payload (its request id) followed by seven
00bytes. This is the form the unit itself uses when it acknowledges your requests, and the form springbok sends. An empty extra was tried and did not satisfy the unit. - For 5011 and 5012: one extra
00byte.
ACK header form: answer in the form the frame arrived in. A compressed
frame’s ACK is a compressed frame (type 5000 - 5000 = 0, the same
transaction id, high bit set); a standard frame’s ACK has a standard header.
Acknowledging a compressed frame with a standard-header ACK was the first
bug found on the bench: the handshake frames (5024, 5050) use standard
headers and were satisfied, everything after them was not.
ACKs you receive (orig_type = 5043 with status 0) tell you the unit
accepted a request; a non-zero status is a NAK worth tracing. Nothing else
needs doing with them.
5.1 Device information (5024) payload
| offset | size | field |
|---|---|---|
| 0 | 2 | protocol version (150 on current units) |
| 2 | 2 | product number (3622) |
| 4 | 4 | unit id |
| 8 | 2 | software version (for example 430 = 4.30) |
| 10 | 2 | max packet size (512) |
| 12 | three length-prefixed strings (1-byte length each): product name, model name, a third usually empty | |
| 1 | a flag byte |
Worth one trace and one log line per link; nothing in the protocol depends on it.
5.2 Protobuf frames (5043 and 5044): the fragmentation header
The payload of a 5043 or 5044 frame begins with a 14-byte header:
| offset | size | field |
|---|---|---|
| 0 | 2 | request id |
| 2 | 4 | offset of this fragment within the message |
| 6 | 4 | total message length |
| 10 | 4 | this fragment’s length |
| 14 | the protobuf bytes |
The R10’s messages always fit one frame (offset = 0, total = chunk); a
multi-part message has never been seen from it. If offset is not 0 or
total differs from chunk, drop the frame. Clamp chunk to the bytes
actually present.
Sending a request: the host keeps a 16-bit request id counter starting
at 1 (it may run across connections); each request is a 5043 frame whose
payload is pack("<I2I4I4I4", id, 0, #pb, #pb) .. pb, where pb is one of
the constant protobuf byte strings in section 6.
6. The protobuf messages
The R10 speaks Google Protocol Buffers (proto2 wire format) inside the fragmentation header. You do not need a protobuf library: the host sends a handful of constant byte strings, and the handful of messages the host must read can be walked with a sixty-line wire-format reader (section 6.3).
Wire-format reminder: each field is a varint tag = field_number << 3 | wire_type, then the value. Wire types: 0 varint, 1 fixed64, 2
length-delimited (a varint length then the bytes), 5 fixed32. Nested
messages are length-delimited fields. Floats are fixed32 (little-endian
IEEE single).
6.1 The message tree
The top-level message is Smart. Only two of its fields matter:
Smart
30 EventSharing (length-delimited)
1 SubscribeRequest host -> unit
1 repeated AlertType varint; 8 = LAUNCH_MONITOR
2 SubscribeResponse unit -> host
1 repeated AlertStatusMessage
1 status varint; 0 = ok
3 AlertNotification unit -> host (a pushed alert)
1 repeated alert type varint
1001 AlertDetails (an extension field)
1 State (message) 1: state enum
2 Metrics (message) a shot, section 6.2
3 Error (message) 1: code, 2: severity
4 CalibrationStatus (message) 1: status, 2: result
38 LaunchMonitorService (length-delimited)
1 StatusRequest host -> unit (empty)
2 StatusResponse unit -> host
1 State (message) 1: state enum
3 WakeUpRequest host -> unit (empty)
4 WakeUpResponse unit -> host
1 status varint; 0 = woke, 1 = already awake
5 TiltRequest host -> unit (empty)
6 TiltResponse unit -> host
1 Tilt (message) 1: roll float, 2: pitch float
7 StartTiltCalibrationRequest host -> unit (empty)
8 StartTiltCalibrationResponse unit -> host
1 status varint; 0 started, 1 in progress, 2 error
11 ShotConfigRequest host -> unit
1 temperature float (F) 2 humidity float (0..1)
3 altitude float 4 air_density float
5 tee_range float
12 ShotConfigResponse unit -> host
1 success varint; non-zero = accepted
The state enum (in State.1, and in AlertDetails.1):
| value | state | what the driver reports (the text is the state word) |
|---|---|---|
| 0 | STANDBY | "waking" “Standby”, and send a wake-up |
| 1 | INTERFERENCE_TEST | "waking" “Interference test” |
| 2 | WAITING | "ready" “Waiting for a swing” - the only source of Ready |
| 3 | RECORDING | "busy" “Recording” |
| 4 | PROCESSING | "busy" “Processing” |
| 5 | ERROR | "error" “R10 reports an error”, then a TiltRequest so the status page shows whether it is level |
Device errors (Error.code): 0 unknown, 1 overheating, 2 radar
saturation, 3 platform tilted. Report "error" with that text when
severity is 1 or more.
Calibration (CalibrationStatus): status 0 unknown, 1 in bounds, 2
recalibration suggested, 3 recalibration required; result 0 success, 1
error, 2 unit moving. Trace both, then send a TiltRequest and a
StatusRequest (springbok’s reaction).
6.2 Metrics: a shot
AlertDetails.2, a Metrics message:
Metrics
1 shot_id u32 (varint)
2 shot_type varint; 0 practice, 1 normal
3 BallMetrics
1 launch_angle float, degrees (VLA)
2 launch_direction float, degrees (HLA, positive right)
3 ball_speed float, METRES PER SECOND
4 spin_axis float, degrees - OPPOSITE SIGN to GSPro's
5 total_spin float, rpm
6 spin_calc_type (unused)
7 ball_type (unused)
4 ClubMetrics
1 club_head_speed float, metres per second
2 club_angle_face float, degrees (face to target)
3 club_angle_path float, degrees
4 attack_angle float, degrees
5 SwingMetrics (timings, unused)
A state may ride along in the same AlertDetails as a shot; the shot is
the event, and the state is taken as the current state.
Conversion to monitor.shot:
shot = {
ball_speed = ball_speed_mps * 2.23694,
vla = launch_angle, hla = launch_direction,
total_spin = total_spin,
spin_axis = -spin_axis, -- GSPro's sign
}
if has_club and club_head_speed_mps > 0.5 then -- the unit sends zeros when it did not track the club
shot.club_speed = club_head_speed_mps * 2.23694
shot.attack_angle = attack_angle
shot.face_to_target = club_angle_face
shot.path = club_angle_path
end
De-duplicate by shot_id: the unit retransmits an unacknowledged shot
alert, and even an acknowledged one may arrive twice. Keep eight (id, time) slots and drop an id seen within the last 60 s.
6.3 The host’s constant requests
Each is the complete protobuf for one Smart message, as bytes. Send them
inside a 5043 frame with the fragmentation header (section 5.2).
| request | bytes | structure |
|---|---|---|
| subscribe to launch monitor alerts | F2 01 06 0A 04 0A 02 08 08 | Smart.30 { .1 { .1 { .1 = 8 } } } |
| wake-up | B2 02 02 1A 00 | Smart.38 { .3 {} } |
| status | B2 02 02 0A 00 | Smart.38 { .1 {} } |
| tilt | B2 02 02 2A 00 | Smart.38 { .5 {} } |
| start tilt calibration | B2 02 02 3A 00 | Smart.38 { .7 {} } |
| shot config | B2 02 1B 5A 19 then 0D <60.0f> 15 <0.5f> 1D <0.0f> 25 <1.225f> 2D <7.0f> | Smart.38 { .11 { 1..5 as fixed32 } }, springbok’s defaults |
(F2 01 is the varint for tag 30 << 3 | 2 = 242; B2 02 is tag 38 << 3 | 2 = 306. Each <x.yf> is four bytes, a little-endian IEEE single.)
6.4 Reading the unit’s messages
Walk the top-level message: for each field, read the tag varint, split it into field number and wire type, and either descend (wire type 2 and a field you care about), read the value (varint, or four bytes for a fixed32 float) or skip (varint: read it; fixed64: 8 bytes; length-delimited: read the length and skip; fixed32: 4 bytes; a group or an unknown wire type ends the walk). Reading a numeric field should accept both encodings: the unit sends floats as fixed32 and integers as varints, but tolerating a varint where you expected a float costs nothing.
Decode to a small event table: kind is one of subscribed (with ok),
wake (with status, ok = status 0 or 1), status or state (with
state), shot (with the metrics), deverr (code, severity), tilt
(roll, pitch), tiltcal (status), calibration (status, result),
shotcfg (ok), or other. A malformed wire inside a nested message only
ends that message; only a malformed top level fails the decode.
Keep every unsigned 32-bit value masked (& 0xFFFFFFFF) and print ids
with %d only after confirming they are small; the device’s Lua integer
is 32-bit signed.
7. The session
This is the order that arms the unit. It is springbok’s order, and it was
reached after the tenover order (which waits for 5024, answers it with
host device information, and sends a host 5050) had left the bench unit
flashing blue in state ERROR while the Garmin Golf app armed it in the same
spot. Keep this order.
on_connect status "handshake"
write REGISTER to reg (rsp)
notify: register response -> handle
write [handle 00] to data the nudge
send WAKE-UP (request id 1) straight away - do NOT wait for 5024
notify: 5024 device info -> ACK, bare (parse for the log)
notify: 5050 capabilities -> ACK, bare
notify: 5044 WakeUpResponse -> ACK (request id + 7 zeros)
status 0 or 1 is fine; log + status "error" on anything else
not yet subscribed -> send SUBSCRIBE
notify: 5044 SubscribeResponse -> ACK
status from the unit's KNOWN state if it has named one, else "waking"
(v8: the bench unit answered ERROR before the subscribe answer, and a
plain "waking" here hid the error from the owner); log the "linked" line
send STATUS REQUEST ask the unit, never assume
send START TILT CALIBRATION, then SHOT CONFIG once per link
notify: 5044 StatusResponse / 5043 alert with State
WAITING -> status "ready" (the LED goes flashing green)
STANDBY -> status "waking", send WAKE-UP (up to 3, 2 s apart)
RECORDING / PROCESSING -> "busy"
ERROR -> "error", send TILT REQUEST
notify: 5043 alert with Metrics -> ACK, dedupe, convert, monitor.shot
Phases worth keeping as a number (they compare in order): idle, registering, waking up, subscribing, subscribed, active.
Never overwrite a known state. The unit’s answers do not arrive in the order you sent the requests: on 2026-10-09 the state answer (ERROR) came between the wake-up answer and the subscribe answer. Keep one function that sets the status from the current state (WAITING ready, RECORDING / PROCESSING busy, STANDBY / INTERFERENCE waking, ERROR error with the last tilt in the text) and call it wherever you would otherwise assume a state.
Why the status request matters. A WakeUpResponse of 1 (ALREADY_AWAKE) does not mean the unit is armed: the bench unit answered 1 while flashing blue. Ready is declared only from the unit’s own state, WAITING, in a StatusResponse or an alert. Ask after every wake-up answer and keep asking every 10 s (section 8); the unit pushes nothing while idle, so the poll doubles as the liveness probe.
Why tilt calibration and shot config. Springbok sends both once the unit
is awake and subscribed; the unit answers them (tiltcal started, often
shotcfg refused, a tilt of roll 0 / pitch 180 when it is lying on its
back). They are part of the sequence that arms the unit and they give the
status page a reason when it is not WAITING.
A fresh bond helps. When a unit that has been through a failed handshake will not arm even under the right order, Unpair, put it in pairing mode and Pair again: a fresh bond and a fresh unit-side session. The bench went green within a minute of that.
8. Timers
All in on_tick, against stamps kept in the connection state.
| timer | value | action on expiry |
|---|---|---|
| handshake | 12 s from on_connect without a SubscribeResponse | trace the phase you were stuck in; "error" “R10 stopped answering”; ble.disconnect() |
| wake retry | 2 s after a wake-up with no state change, while waking or while the state is STANDBY | send another wake-up, at most 3 per attempt; reset the count when the state changes |
| state poll | every 10 s once subscribed | send a status request |
| silence | 75 s without any frame once subscribed | "error" “R10 stopped answering”; ble.disconnect(); stamp, so one report per silence |
| stuck: re-wake | 5 s into ERROR (the tilt calibration has answered by then) | a wake-up, at most 3 per attempt, 2 s apart |
| stuck: fresh session | ERROR or STANDBY for longer than the hold: 20 s, doubling per round, at most 10 min | one Env line, ble.disconnect() (the device reconnects by address on its own), the round count into the store |
The self-recovery ladder (v9). A launch monitor in the field has nobody
to press its button. Whatever state the unit is in, the driver works it
without a hand on the unit: ask (status), calibrate (tilt), re-wake, then
give the unit a fresh session by dropping the link, with a hold that
doubles per round so a unit that never recovers costs one Env line every
ten minutes, not a storm. Keep the round count in the driver’s store
(foogolf.store("err_rounds"), "err_at" = the uptime it was written at)
because the device closes the driver’s VM between sessions; a WAITING
state writes "0", and an uptime that went backwards (a device restart)
reads as zero. Put the round in the status text (R10 error; tilt r0 p180; retry 3) so the owner can see the device is working on it. The ladder
cannot bond afresh: a new bond needs the unit in pairing mode.
| shot dedupe | 60 s window, 8 slots | drop a repeated shot_id |
The device calls on_tick every 50 ms, so none of these need to be exact.
On every error path: monitor.trace the specific cause, monitor.log once,
monitor.status("error", text), then ble.disconnect(). The device
reconnects 1.5 s later and on_connect starts a clean session.
9. What the owner sees, and what to trace
The status page’s Steps list (the last forty monitor.trace lines
since firmware 0.8.648, ten before it, each with the device’s uptime) is the
only diagnostic an owner can send you. A whole handshake plus the first
polls is about twenty-five lines, so keep each one short. The published
driver traces: mtu N; REGISTER, registered hN, nudge 00 sent, rx <type> [t<txn>] for every frame until active, devinfo p<product> sw<version>, wake-up <status>, subscribed ok|refused, tilt cal + shot config, tiltcal <status>, shotcfg ok|refused, tilt r<roll> p<pitch>,
state <name>, READY, SHOT <id> <mph> mph, shot refused: <why>, ack 5043 t<txn> <hex> once per link (the form the unit uses for its ACKs), NAK <status> msg <type>, proto err <code> (<arg>), stuck at <phase>.
Version 7 added the lines that say why nothing is happening, each once
per link: state answer: no state (a StatusResponse or alert whose State
did not decode - the unit answered), other msg top <fields> sub <field> (a
Smart message the decoder does not know, with its top-level field numbers
and the LaunchMonitor sub-field), proto undecodable, write FAIL <why>
(the first ble.write that returned false - a state request or a wake-up
whose write fails must not fail silently), no state answer (2 polls) (two
state requests in a row without a state back). Trace the silence as well as
the traffic: a Steps list that stops at tilt cal + shot config cannot be
told apart from a unit that never answered, and that is exactly what the
2026-10-09 bench produced.
The Env log gets one line per link (linked (hN, <product names>, unit <id>, proto <v>, sw <v>, max packet <n>), alerts subscribed (ok)), one
state <name> line (the first answer), one line per shot with every
metric, and one per device error or protocol error. The device prefixes
each with the driver’s name.
Protocol error codes used by the published driver, for the trace: 1 register refused, 2 handshake timeout, 3 CRC, 4 COBS, 5 write failed, 6 silence, 7 reassembly overflow. CRC and COBS errors are traced but do not end the link; the others do.
10. Checklist for a reimplementation
A driver written from this document should, against the published driver’s recorded sessions:
- Write REGISTER (13 bytes,
rsp) toregfromon_connect. - After the register response: write
[handle] 00todata, then the wake-up as a 5043 frame with request id 1, in 20-byte chunks each starting with the handle byte,norsp. - ACK 5024 and 5050 with
orig u16, 00and nothing else, standard header. - ACK every 5043 / 5044 with
orig u16, 00, <request id u16>, 00 x 7, in the header form the frame arrived in, echoing a compressed frame’s transaction id; ACK before decoding. - Subscribe only after a WakeUpResponse; after the SubscribeResponse send status, tilt calibration and shot config.
- Declare
"ready"only on WAITING;"busy"on RECORDING / PROCESSING; re-wake on STANDBY;"error"plus a TiltRequest on ERROR. - Convert m/s to mph, negate the spin axis, include club data only when the club speed exceeds 0.5 m/s, drop a repeated shot id within 60 s.
- Run the four timers in section 8 and end every failure with
monitor.status("error", ...)andble.disconnect().
FooGolf’s offline harness (foogolf-ota-cloud/extension-store/tests/ test_garmin_r10.py) replays four recorded R10 sessions through a driver
and compares every write and every report; it is written for the published
driver but its session fixtures are plain JSON and apply to any
reimplementation.
Rendered from golfclaude/docs/lua/garmin-r10-protocol.md at 8ef01dd, 2026-10-26. The Markdown in the repository is the source; this page follows it on every push.