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:

LEDmeaning
solid bluepairing mode (hold the button until it goes solid); the only time it accepts a new bond
flashing blueconnected at the radio level, “reconnecting to the previous phone”: no application session yet
flashing greenarmed and waiting for a swing - the goal
offasleep 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 product 3622 (0x0E26) little-endian - the two bytes 26 0E, hence the prefix rule - and the name Approach 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, alternatively 2812), and one that takes data writes (2820, alternatively 2822). 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_hint says 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 on reg, each carrying the handle byte first, and a frame routinely spans several notifications.
  • Writes. REGISTER goes to reg with response ("rsp"); everything afterwards goes to data without response ("norsp"). Those are preferences; the device uses what each characteristic supports.

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):

offsetsizefield
0100
1101 = register response
28transaction id
102service id
121status: 0 = ok, anything else = refused
131handle
142flags

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:

offsetsizefield
02len: the whole frame’s length in bytes including this field and the CRC
22 or 2the header, in one of two forms (below)
4len - 6payload
len - 22CRC-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

typenamedirectionwhat the host does
5000ACKboththe only reply to any other frame
5011FIT definitionunit to hostACK with status 0 and one extra 00 byte; ignore the contents
5012FIT dataunit to hostsame
5024device informationunit to hostparse for the log (section 5.1); ACK with status 0 and no extra bytes
5043protobuf requestbothACK (section 5.2), then decode the protobuf
5044protobuf responseunit to hostACK (section 5.2), then decode the protobuf
5050configuration / capabilitiesunit to hostACK with status 0 and no extra bytes
otherunit to hostACK 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 tenover write-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 00 bytes. 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 00 byte.

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

offsetsizefield
02protocol version (150 on current units)
22product number (3622)
44unit id
82software version (for example 430 = 4.30)
102max packet size (512)
12three length-prefixed strings (1-byte length each): product name, model name, a third usually empty
1a 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:

offsetsizefield
02request id
24offset of this fragment within the message
64total message length
104this fragment’s length
14the 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):

valuestatewhat the driver reports (the text is the state word)
0STANDBY"waking" “Standby”, and send a wake-up
1INTERFERENCE_TEST"waking" “Interference test”
2WAITING"ready" “Waiting for a swing” - the only source of Ready
3RECORDING"busy" “Recording”
4PROCESSING"busy" “Processing”
5ERROR"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).

requestbytesstructure
subscribe to launch monitor alertsF2 01 06 0A 04 0A 02 08 08Smart.30 { .1 { .1 { .1 = 8 } } }
wake-upB2 02 02 1A 00Smart.38 { .3 {} }
statusB2 02 02 0A 00Smart.38 { .1 {} }
tiltB2 02 02 2A 00Smart.38 { .5 {} }
start tilt calibrationB2 02 02 3A 00Smart.38 { .7 {} }
shot configB2 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.

timervalueaction on expiry
handshake12 s from on_connect without a SubscribeResponsetrace the phase you were stuck in; "error" “R10 stopped answering”; ble.disconnect()
wake retry2 s after a wake-up with no state change, while waking or while the state is STANDBYsend another wake-up, at most 3 per attempt; reset the count when the state changes
state pollevery 10 s once subscribedsend a status request
silence75 s without any frame once subscribed"error" “R10 stopped answering”; ble.disconnect(); stamp, so one report per silence
stuck: re-wake5 s into ERROR (the tilt calibration has answered by then)a wake-up, at most 3 per attempt, 2 s apart
stuck: fresh sessionERROR or STANDBY for longer than the hold: 20 s, doubling per round, at most 10 minone 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:

  1. Write REGISTER (13 bytes, rsp) to reg from on_connect.
  2. After the register response: write [handle] 00 to data, then the wake-up as a 5043 frame with request id 1, in 20-byte chunks each starting with the handle byte, norsp.
  3. ACK 5024 and 5050 with orig u16, 00 and nothing else, standard header.
  4. 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.
  5. Subscribe only after a WakeUpResponse; after the SubscribeResponse send status, tilt calibration and shot config.
  6. Declare "ready" only on WAITING; "busy" on RECORDING / PROCESSING; re-wake on STANDBY; "error" plus a TiltRequest on ERROR.
  7. 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.
  8. Run the four timers in section 8 and end every failure with monitor.status("error", ...) and ble.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.