Launch monitor drivers - the reference

Status: API v2, built 2026-10-09 (firmware after 0.8.645, store validator deployed the same day). This is the complete description of what a driver can do and what the device does for it. Read it with garmin-r10-protocol.md, which applies every part of it to one real launch monitor, the Garmin Approach R10, in enough detail to write that driver from scratch. The published R10 driver (share code DPF2L, version 9) is the reference implementation; its source is open on the store like every extension’s, but nothing in this document assumes you have read it.

API v2 is a superset of v1: every v1 driver still loads. What v2 adds is listed in section 14.

Companion documents: developer-guide.md (how extensions reach a device, the store, the sandbox), api-reference.md (the conventions shared by every extension kind, and the foogolf.store key/value store), and monitor-drivers-design.md (the design record and the user decisions behind this API).


1. What a driver is

FooGolf is a putting launch monitor. During a sim session (GSPro through the FooGolf connector, or the Browser sim) a golfer also wants full swings, and those come from an external launch monitor over Bluetooth Low Energy. A driver is a store extension of kind monitor that teaches the device one monitor’s protocol:

  • The device owns the radio. It scans for the monitor using the rules in your manifest, connects, pairs (bonds) when you ask, discovers the services and characteristics you name, turns on the notifications or indications you ask for, keeps the link up, reconnects when it drops, reads the battery level, and shows the owner a status page.
  • The driver owns the protocol. It receives every notification as a Lua byte string, writes to and reads from the characteristics it named, keeps whatever state machine the monitor needs, remembers what it must across reboots in its own key/value store, and reports three things back: the monitor’s status, each shot in GSPro units, and optionally the battery level. The device range-checks the shot and hands it to the active sim session.

One monitor is paired at a time, so one driver is active at a time. Pairing a monitor that another driver claims unpairs the old one. A driver is never an experience: it has no cell on the Games or Drills rows, no settings page, no settings page. It is reached from its own cell on the Launch monitors row, named by the driver’s name (since 2026-10-12, fw 0.8.658; on the Sim golf row from 2026-10-09; the row is not shown at all until a driver is installed), whose list is Status / Pair a launch monitor / Unpair (only on the paired driver’s) / About (the manifest’s about). A name over about twelve characters is dotted on the grid cell, so keep it short.

A driver gets the sandboxed Lua base libraries, the ble and monitor tables and the foogolf.store family, and nothing else: no network, no screen, no audio, no player data, no device settings. A driver published by a stranger can talk to the one peripheral it matched, keep a little private state on the device, and report shots and status. That is the whole security argument, so the limits in this document are enforced, not advisory.

Which features does my monitor need?

Best knowledge from public protocol write-ups and connector projects; none of these but the R10 has been run against a FooGolf device. Check your monitor.

MonitorMatch ruleBondPersistent stateble.readIndicationshlaSpin asNote
Garmin Approach R10manufacturer 0x0087 + prefix, or nameJust Worksnononoyestotal + axisgarmin-r10-protocol.md
Rapsodo MLM2PROservice UUID or namenone (app-level auth)yes - the user token (secret_prompt) and an issued keylikelynoyesback + sideencrypted payloads: needs a crypto binding that does not exist yet (section 13)
Swing Caddie SC300 / SC300iname prefixnonenononoabsenttotal onlyone length-prefixed frame per notification
PRGR / Voice Caddie SC4-classname prefixnonenononovariesvaries
Square Golfservice UUIDJust Worksnomaybenoyesback + side
FlightScope Mevo (BLE models)service UUIDnonemaybelikelymaybeyesback + side

2. The manifest

A driver is one Lua file. It calls foogolf.monitor{...} once at load time; everything after that is hook definitions and your own code. The device reads the manifest once, when the extension is installed or at boot, in a throwaway Lua state, and keeps only the manifest. Your hooks do not run then.

foogolf.monitor{
  name    = "Example LM",          -- required: 1-23 characters. The menu, the status
                                   -- page and the log use this name, never the
                                   -- advertised Bluetooth name.
  version = 1,                     -- integer; shown on the store and in Device status
  author  = "you@example.com",
  about   = "One or two sentences on what this driver connects and what it feeds.",

  -- SCAN RULES. 1-8 rules; the advertisement matches if ANY rule matches,
  -- and a rule matches only if ALL of its fields hold. Each rule needs at
  -- least one field. A service UUID is the usual way to identify a monitor.
  match = {
    { service = "0000FFF0-0000-1000-8000-00805F9B34FB" },  -- the advertisement lists this service
    { name_prefix = "ExampleLM" },                         -- the advertised name starts with this (1-15 chars)
    -- The Garmin R10's rule, as the particular case: manufacturer-specific
    -- data whose company id is Garmin's and whose payload starts with the
    -- product number 3622 (0x0E26 little-endian):
    -- { manufacturer = 0x0087, prefix = "\x26\x0e" },     -- prefix: 1-8 bytes after the company id
  },

  -- THE SERVICE(S) the characteristics belong to. One of:
  service  = "0000FFF0-0000-1000-8000-00805F9B34FB",       -- a 128-bit UUID, or 16-bit as four hex digits ("180D")
  -- services = { main = "0000FFF0-...", aux = "180F" },   -- up to two; a chars entry may name "aux"

  -- CHARACTERISTICS by your own key. 1-8 keys of 1-11 characters. A value is
  -- one UUID, a list of 1-4 ALTERNATIVE UUIDs (see "channels"), or a table
  -- { service = "main"|"aux", uuid = <UUID or list> }.
  chars = {
    cmd  = "FFF1",
    evt  = "FFF2",
    batt = { service = "aux", uuid = "2A19" },
  },

  -- Keys whose CCCD the device turns on before on_connect: a plain string
  -- means notifications; { key = ..., indicate = true } means indications
  -- (the device confirms each indication itself; you still get on_notify).
  subscribe = { "evt", { key = "cmd", indicate = true } },

  -- Pairing: false (none), true (Just Works bonding, no PIN) or "passkey"
  -- (section 3, "Pairing methods").
  bond = true,

  -- How the paired monitor is found again after a drop: "address" (default)
  -- connects to the stored address; "scan" re-runs the match rules first.
  -- "scan" is for a monitor that uses resolvable private addresses and is
  -- not bonded, so its address changes.
  reconnect = "address",

  -- Shown on the pairing page while the device scans (up to 71 characters).
  pairing_hint = "Turn the monitor on and keep it within a metre",
  -- Shown on the status page while the PAIRED monitor does not answer a
  -- connection attempt - asleep or off (up to 47 characters).
  wake_hint = "Off? Press the monitor's power button",
  -- A credential the owner obtains elsewhere (an app, a website). When
  -- present the device asks for it once after a successful pair, with this
  -- text as the keyboard's title (up to 47 characters), and saves it as
  -- foogolf.recall("secret") in the driver's store (section 7).
  secret_prompt = "Rapsodo user token (from the app)",
}

Alternatives by position (“channels”). This exists because the Garmin R10 exposes duplicate characteristic pairs and only one pair is live. When a key lists several UUIDs, the device uses the first position at which every key’s characteristic exists: position 1 is every key’s first UUID, position 2 every key’s second, and a key with a single UUID fits every position. Your hooks never learn which position won: you write by key. Most monitors will list one UUID per key and never think about this.

Validation. The store refuses a manifest that breaks any limit above (the refusal names the field), and the device refuses it again at install with the same message, then removes the extension. A name over 23 characters, a ninth rule, a ninth key, a key over 11 characters, a subscribe entry that is not a key, a chars entry on aux when no aux service is declared, a service that is not a UUID: each is a refusal, not a warning. product = n is still accepted in a match rule as the deprecated form of a two-byte little-endian prefix.

Not in the manifest. There is still no way to name a third service, to ask the device to type a passkey the monitor displays (section 3), or to declare a scan filter on anything but the service list, the manufacturer data and the name. If your monitor needs one of those, say so on the forum; the API grows by request.


3. The lifecycle

Everything below happens on the device without your code, except the hooks.

Install. The owner installs your driver by share code (Settings > Manage extensions > Install by code) or from Available. The device reads the manifest. The confirmation reads “Installed (launch monitor driver)” and the driver’s cell appears on the Launch monitors row. A driver counts toward the sixteen installed extensions but appears on no Games or Drills row. The device describes at most four installed drivers.

Pair. Launch monitors > <your driver’s cell> > Pair a launch monitor. The device starts Bluetooth, scans actively against YOUR match rules only, and lists the monitors it sees, your pairing_hint underneath. One monitor is paired at a time: pairing through your cell while another driver’s monitor is paired replaces that pairing - its bond and its key/value store go. Once the bond, discovery and subscription succeed the page stays up as the link is made (since firmware 0.8.650): it shows the status line your monitor.status texts feed and the last six monitor.trace lines scrolling underneath, and returns to your driver’s list the moment you report "ready". Trace the steps that matter, briefly: this page is where the owner watches your driver work. OK connects, pairs as bond says, discovers your services and characteristics, subscribes, and stores the link record: the monitor’s address, its advertised name, your share code, and the bond keys. If the manifest has a secret_prompt, the on-screen keyboard then asks for it (up to 64 characters typed on the device; Cancel keeps the pairing and stores nothing). The flash reads “Paired with ”. Pairing runs no Lua. The driver’s key/value store starts empty at every new pairing.

Pairing methods. bond = false: no pairing; the link is unencrypted. bond = true: Just Works bonding, no PIN, Secure Connections first and legacy pairing as the retry. bond = "passkey": the device is a display-only peer with man-in-the-middle protection on. If the monitor asks the device to display a passkey, the pairing page shows the six digits and the owner types them on the monitor. If the monitor asks for a numeric comparison, the page shows the number and the device accepts it. If the monitor displays a passkey and expects the device to type it, that is not supported: the pairing times out with “Pairing failed” and the Env log says why. The device never pairs out-of-band.

Link up. From then on, whenever the device is running and the paired driver is installed, the device keeps the link up (firmware 0.8.636; earlier firmware linked only during a sim session). With reconnect = "address" it connects to the stored address, directed advertisements included, which is what a bonded peripheral sends; with reconnect = "scan" it scans for up to 8 s and takes the match whose advertised name is the stored one, or the only match once 3 s have passed. After a connect and a successful subscribe the device opens your Lua state and calls on_connect. The status page reads Searching, then Connecting, then whatever you report.

Running. The device delivers every notification and indication to on_notify, calls on_tick every 50 ms, and re-reads the Battery Service level (180F / 2A19, if the monitor has it) every five minutes for the status page, unless your driver has reported the battery itself.

Link down. When the link drops, the device calls on_disconnect, closes your Lua state, waits 1.5 s and reconnects, then opens a fresh state and calls on_connect again. Every connection starts from a fresh Lua state: a global does not survive a reconnect. What must survive goes in the key/value store (section 7). A connection attempt that times out means the monitor is off or asleep; the status page then shows your wake_hint, and the device keeps trying every 6 s.

Interruptions. The device tears Bluetooth down before every TLS call it makes on its main loop (a firmware update, an extension install or update, a manual update check, the store) and brings it back afterwards. You see an on_disconnect and later an on_connect. Nothing you can do changes this. If Bluetooth cannot start because internal RAM is short (under 20 KB would remain free), the status reads “Off (RAM)” and the device retries a minute later.

Driver error. If any hook raises a Lua error, including the runtime’s own “extension ran too long”, the device ends the link, shows “Driver error: ” on the status line and in the Env log, and leaves the link down. It tries again when a sim session starts, when the extension list is reloaded (an install or an update of any extension) or after a restart. An error never stalls the sim session or the putting side of the device.

Unpair. Launch monitors > <your driver’s cell> > Unpair forgets the record and the bond and wipes the driver’s key/value store. The monitor keeps its side of the bond until it pairs again; that is expected.

Uninstall. Removing the driver while its monitor is paired leaves the pairing in place: a generic “Launch monitor” cell with the status “Its driver is not installed” stays on the row until the driver is installed again or the monitor is unpaired from that cell. The driver’s key/value store is wiped completely at uninstall, pairing kept or not, so a reinstall starts empty.


4. The hooks

Define any or all of these as globals. They run on the device’s Bluetooth task, never on the main loop, so nothing you do can delay a putt’s capture.

function on_connect(info) end     -- info = { mtu = n, name = "advertised name" }
function on_notify(key, bytes) end
function on_tick(ms) end
function on_disconnect() end
  • on_connect(info) - the link is up, the bond (if any) is in place and every subscribe key is subscribed. Start your handshake here. info.mtu is the negotiated ATT MTU (23 until exchanged; the device asks for the maximum, so expect up to 247); info.name is the monitor’s advertised name. This is the first hook in every connection and it runs in a fresh Lua state, so initialise everything here rather than at file level if it must be per-connection.
  • on_notify(key, bytes) - one notification or indication from the characteristic you named key, as a Lua string of raw bytes (#bytes is its length, which is at most 247). They are delivered in the order received, one call each, from a queue of 32; if the queue overflows, the oldest are dropped. A monitor that acknowledges at the application level (the R10 does) will retransmit what you did not acknowledge; a monitor that does not will not, so size your handling for the burst your monitor sends. A notification longer than 247 bytes is dropped before it reaches you.
  • on_tick(ms) - every 50 ms while connected; ms is the device’s uptime in milliseconds (the same clock as monitor.millis(); wraps after 49 days - subtract, never compare). Run your timers here: handshake deadlines, retries, polls, silence watchdogs.
  • on_disconnect() - the link dropped or the device is closing it. Your state is discarded straight after this call, so there is nothing to clean up; the hook exists so you can log or trace the end of a session, or foogolf.store something for the next one.

A hook returns nothing. A hook that raises is a driver error (section 3). A hook may run at most 100,000 Lua instructions per call; on_notify decoding a frame or on_tick checking timers is far under this, but a loop that computes a 64 KB CRC table every call is not.


5. The ble table

ok, why    = ble.write(key, bytes [, mode])
bytes, why = ble.read(key)
n          = ble.mtu()
             ble.disconnect()

ble.write(key, bytes [, mode]) writes bytes (a Lua string) to the characteristic you named key on the connected monitor. Returns true, or false, reason.

  • mode is "norsp" (the default) or "rsp". It is a preference: a characteristic that lacks the requested write type gets the other. A write with response blocks until the monitor acknowledges or 3 s pass; a write without response returns as soon as the stack accepted it.
  • The device does not split a long write for you. Keep each write within what the monitor accepts: ble.mtu() - 3 is the hard ceiling, and a monitor designed against the 23-byte minimum MTU may take only 20 bytes per write whatever was negotiated (the R10 does). Chunk in Lua.
  • A key that is not in your manifest raises a Lua error (a driver error); a write while not connected returns false, "not connected". Any other failure returns false with the stack’s reason text.

ble.read(key) reads the characteristic’s value: returns the bytes as a Lua string, or nil, reason ("not readable" when the characteristic has no read property, "not connected", "read failed"). It blocks for up to 3 s and may be called from any hook. Up to 256 bytes are returned. A key not in the manifest raises, like ble.write.

ble.mtu() returns the current ATT MTU (23 before the exchange).

ble.disconnect() drops the link. Use it when the protocol is wedged: the device will call on_disconnect, wait 1.5 s and reconnect, and you start again in on_connect. It is not an error, and it does not set the driver error state. If the owner should know why the link flickered, send monitor.status("error", ...) first.


6. The monitor table

          monitor.status(state [, text])
ok, why = monitor.shot{ ... }
          monitor.battery(percent | nil)
          monitor.trace(text)
          monitor.log(text)
ms      = monitor.millis()

monitor.status(state [, text]) sets the monitor’s state as shown on the Launch monitor cell (the label), its status row and its status page (the label, then your text). state is one of these strings (anything else raises); text (up to 60 characters) is optional and shown with every state. A driver may use any subset of the states.

statewhat the owner should understandlabel
"handshake"connected, the driver is still setting upConnecting
"waking"connected and set up, but the monitor will not report a swing yet (asleep, no ball detected, aligning, standby, …)Not ready
"ready"the next swing will be reportedReady
"busy"a swing is being measured or processedShot
"error"a fault; the text says whatError

Report every change; the device coalesces repeats. Report "ready" only when you know the next swing will be reported. For most monitors that is their own armed or waiting message; for a monitor with no arming step, report it as soon as the handshake completes. Owners read Ready as “swing now”.

monitor.shot{...} reports one measured shot in GSPro’s units. Returns true, or false, reason (never raises on bad data).

fieldunitrequiredaccepted range
ball_speedmphyes1 to 250
vladegrees, vertical launch angle, positive upyes-20 to 90
hladegrees, horizontal launch angle, positive = rightno, default 0-60 to 60
total_spinrpmno0 to 20000
spin_axisdegrees, GSPro’s sign (positive tilts the spin axis right, a fade for a right-hander)no-90 to 90
back_spinrpm, the alternative to the pair aboveno0 to 20000
side_spinrpm, positive = fade for a right-hander (the same sense as a positive spin_axis)no-20000 to 20000
club_speedmph; a value above 0 means club data is presentno0 to 200
attack_angledegrees, positive upno; default 0 when club data is present-30 to 30
face_to_targetdegrees, positive = open to the rightno; default 0 when club data is present-45 to 45
pathdegrees, positive = in-to-out for a right-handerno; default 0 when club data is present-45 to 45
carryyards, the monitor’s own carryno0 to 500
totalyards, the monitor’s own totalno0 to 500

A missing required field or any present value outside its range returns false, "<field> missing or out of range" and the shot is dropped - the device does not clamp. Absent optional fields are not checked. A monitor with no horizontal measurement simply omits hla. A monitor with no launch angle is not supportable: the sim cannot compute a flight without vla, and the device will not invent one.

Spin: give total_spin and spin_axis, or back_spin and side_spin. If both pairs are present, total_spin and spin_axis win. The device converts the pair with total = sqrt(back² + side²) and axis = atan2(side, back) in degrees. Convert your monitor’s units in the driver (metres per second times 2.23694 is mph) and check the sign conventions against GSPro’s: several monitors report the spin axis with the opposite sign.

Distances: carry and total are accepted, range-checked and passed to the sim session as information. The FooGolf sims compute their own flight from the ball data; the connector forwards carry to GSPro as its CarryDistance, which GSPro may use or ignore.

The device stamps its own shot number and hands the shot to the active sim session. A shot goes to a sim session only: while a drill, a game or a menu is open, the shot is accepted (true) and discarded. The device returns false, "shot queue full" when four shots are already waiting for the main loop, which does not happen in play. The device does not de-duplicate. If your monitor retransmits a shot report (the R10 does), keep the last few shot ids and drop a repeat yourself (section 9).

monitor.battery(percent) reports the monitor’s battery level (an integer 0 to 100) for the status page; nil clears your value. The device also reads the standard Battery Service itself when the monitor has one. A driver value overrides the Battery Service value until the link drops; the status page says which source it shows.

monitor.trace(text) appends one line to the Steps list on the status page (the last ten lines are shown, each stamped with the device’s uptime in seconds). It costs nothing and goes nowhere else: use it freely for every protocol step, so that an owner on the forum can tell you exactly where the handshake stopped.

monitor.log(text) writes one line to the device’s Env log (Settings > Dev > Logging > Latest logs), the 100-line ring every bug report starts from. The device prefixes the line with your driver’s name and rate-limits it to one line per two seconds; lines over the limit are counted and the count is appended to the next line that gets through. Log rare events only: the link coming up, a shot, a protocol error. Never log per notification or per tick.

monitor.millis() returns the device’s uptime in milliseconds, the same clock on_tick receives.


7. Persistent state: foogolf.store, recall, store_keys

Every extension kind has the same small key/value store; api-reference.md documents it in full. For a driver:

ok, why = foogolf.store(key, value)   -- key 1-16 chars; value a string of up to 512 bytes, or nil to delete
value   = foogolf.recall(key)         -- the string, or nil
keys    = foogolf.store_keys()        -- a table of the keys present
  • Scope: the paired link record. The values belong to this driver’s pairing with this monitor. They survive reboots, reconnects and driver updates under the same share code. A new pairing (the same driver, the same or another monitor) starts the store empty; Unpair wipes it; uninstalling the driver wipes it even while the pairing record is kept. Never shared with another extension.
  • Budget: 2048 bytes of keys plus values per scope, at most 32 keys. store returns false, "store full" when a write would exceed it, false, "value too long" over 512 bytes, false, "key too long" over 16 characters. A wrong type raises.
  • The secret. With secret_prompt in the manifest, the device saves what the owner typed as the key "secret" after pairing. Read it with foogolf.recall("secret") in on_connect; nil means the owner cancelled. A real Rapsodo MLM2PRO user token is a decimal number of at most ten digits, well inside the 512-byte value limit.
  • Flash is written lazily. store changes a table in RAM and the device flushes it to flash a few seconds later, at every disconnect, and before a restart. A driver calling store in a hot path cannot wear the flash or stall the Bluetooth task. A value stored in the last moments before a power cut can be lost; a value stored and then followed by a disconnect cannot.
  • Public file, private values. Your Lua file is public on the store; the values are on the owner’s device only and never leave it. Uninstall erases them.

8. The sandbox and its limits

Lua5.4, 32-bit integers and single-precision floats (the firmware’s LUA_32BITS build)
Librariesstring (with pack / unpack), table, math, utf8, coroutine, and the base functions pairs ipairs select tostring tonumber type pcall error assert next rawget rawset rawequal rawlen setmetatable getmetatable
Not availableio, os, require, load, loadstring, dofile, debug, package, print, every other extension module (canvas, hud, audio, net, players, settings, device, timer, util)
Heap128 KB for your Lua state, in PSRAM. Device status shows the running driver’s heap peak; FooGolf’s offline harness budgets 96 KB to leave headroom.
Instructions100,000 per hook call
Source64 KB
Notification247 bytes each, a queue of 32
ble.read256 bytes, 3 s
Shot queue4
Store2048 bytes, 32 keys, values of 512 bytes
Logone line per 2 s
Tracethe last 10 lines

32-bit integers deserve a paragraph. A Lua integer on the device is a signed 32-bit value. string.unpack("<I4", s) of a value with the top bit set gives you a negative number; 1 << 31 is negative; CRC-32 arithmetic needs & 0xFFFFFFFF after every shift and still prints negative. Mask every value you treat as unsigned before comparing it, compare only masked values, and format them with %08x rather than %d. string.pack("<I4", v) works for any value in either representation. Floats are single precision: do not accumulate thousands of millisecond deltas in one float.

Byte strings. A notification arrives as a Lua string; string.byte, string.sub, string.pack / string.unpack and .. are the tools. Strings are immutable; collect pieces in a table and table.concat them rather than building a long string with repeated .. in a loop. A hex dump for tracing is one line: (s:gsub(".", function(c) return string.format("%02x", c:byte()) end)).


9. A skeleton driver

This is a complete, valid driver for an imaginary monitor that sends one length-prefixed frame per notification and reports a shot as four little-endian floats. It has no horizontal measurement, so hla is omitted. It shows the shape every driver has: constants, a connection-scoped state table, a frame decoder, a handshake started in on_connect, timers in on_tick, and the reports.

foogolf.monitor{
  name = "Example LM", version = 1, author = "you@example.com",
  about = "Example launch monitor over BLE.",
  match = { { service = "0000FFF0-0000-1000-8000-00805F9B34FB" }, { name_prefix = "ExampleLM" } },
  service = "0000FFF0-0000-1000-8000-00805F9B34FB",
  chars = { cmd = "FFF1", evt = "FFF2" },
  subscribe = { "evt" },
  bond = false,
  pairing_hint = "Turn the monitor on and keep it within a metre",
  wake_hint = "Off? Press the monitor's power button",
}

local unpack, sub, byte = string.unpack, string.sub, string.byte

local HANDSHAKE_MS, POLL_MS, SILENCE_MS = 10000, 5000, 60000
local S = {}                     -- per-connection state; rebuilt in on_connect

local function send(frame)       -- 1-byte length prefix, chunked to 20 bytes
  local wire = string.char(#frame) .. frame
  for i = 1, #wire, 20 do
    local ok, why = ble.write("cmd", sub(wire, i, i + 19))
    if not ok then monitor.trace("write failed: " .. tostring(why)); return false end
  end
  return true
end

local function fail(text)
  monitor.log(text)
  monitor.status("error", text)
  ble.disconnect()               -- the device reconnects; on_connect starts over
end

local function handle(frame)
  local kind = byte(frame, 1)
  if kind == 0x01 then           -- ARMED
    S.armed = true
    monitor.trace("armed"); monitor.status("ready", "Waiting for a swing")
  elseif kind == 0x02 then       -- SHOT: ball m/s, vla, back spin, side spin (floats)
    local mps, vla, back, side = unpack("<ffff", frame, 2)
    monitor.trace(string.format("shot %.1f m/s", mps))
    -- No horizontal measurement on this monitor: hla is simply omitted.
    local ok, why = monitor.shot{ ball_speed = mps * 2.23694, vla = vla,
                                  back_spin = back, side_spin = side }
    if not ok then monitor.trace("shot refused: " .. tostring(why)) end
    -- foogolf.store("shots", tostring((tonumber(foogolf.recall("shots")) or 0) + 1))  -- survives reboots
    monitor.status("ready", "Waiting for a swing")
  elseif kind == 0x03 then       -- BUSY
    monitor.status("busy", "Measuring")
  elseif kind == 0x04 then       -- BATTERY: one byte, percent
    monitor.battery(byte(frame, 2))
  end
end

function on_connect(info)
  S = { rx = "", armed = false, start = monitor.millis(), last_rx = monitor.millis(), last_poll = 0 }
  monitor.status("handshake", "Saying hello")
  monitor.trace(string.format("mtu %d, hello", info.mtu))
  if not send("\0") then fail("Could not send hello") end     -- HELLO
end

function on_notify(key, bytes)
  S.last_rx = monitor.millis()
  S.rx = S.rx .. bytes                                        -- reassemble: a frame may span notifications
  while #S.rx >= 1 do
    local n = byte(S.rx, 1)
    if #S.rx < 1 + n then break end
    local frame = sub(S.rx, 2, 1 + n)
    S.rx = sub(S.rx, 2 + n)
    handle(frame)
  end
  if #S.rx > 512 then S.rx = ""; monitor.trace("resync") end
end

function on_tick(ms)
  if not S.armed and ms - S.start > HANDSHAKE_MS then return fail("Monitor did not arm") end
  if ms - S.last_poll > POLL_MS then S.last_poll = ms; send("\2") end   -- STATUS poll
  if ms - S.last_rx > SILENCE_MS then return fail("Monitor stopped answering") end
end

function on_disconnect() end

10. Patterns every driver needs

These are the pieces of the R10 driver that are not Garmin’s and that your driver will contain too. Each is a dozen lines; the point of listing them is that you write them deliberately.

Reassembly. A notification is not necessarily a frame. Keep a receive buffer per connection, append each notification, and extract frames by whatever the monitor’s framing is: a delimiter byte (strip and decode what lies between), or a length prefix (wait until the whole frame is present). Cap the buffer and clear it on overflow so a corrupt byte cannot wedge the link. A monitor that sends exactly one frame per notification needs none of this.

Chunked writes. Decide the chunk size from the monitor’s documentation: ble.mtu() - 3 is the ceiling, 20 bytes the safe default for a monitor designed against the minimum MTU, and some protocols (the R10’s MultiLink) want a channel byte on every chunk. Loop over ble.write and stop at the first false.

Timers. Keep a few millisecond stamps in the connection state - when the handshake started, when the last byte arrived, when the last poll went out, when the last retry went out - and compare them in on_tick. Give every wait a deadline: a handshake that does not complete, a monitor that goes silent, a retry that gets no answer. On a deadline, trace the phase you were stuck in, set "error" with a short text, and ble.disconnect().

Acknowledge first, then act (needed by monitors that retransmit; the R10 does). When a protocol requires you to acknowledge a frame, send the acknowledgement before decoding the frame’s contents. The retransmits of an unacknowledged frame arrive while you are still handling the first copy.

De-duplicate shots (needed by monitors that retransmit; the R10 does). Keep the last eight shot ids with their times and drop a repeat seen within the monitor’s retransmit window.

Report the monitor’s own state. Map the monitor’s state words onto the five status strings and send monitor.status on every change, with the monitor’s word as the text. Set "ready" from the monitor’s own armed or waiting message, or from the end of the handshake when it has none.

Trace generously, log rarely. Every protocol step is a monitor.trace; the link coming up, a shot, and an error are monitor.log.

Convert units in one place. One deliver_shot(decoded) function that converts speeds to mph, flips any sign that differs from GSPro’s, decides whether club data is present, calls monitor.shot and traces the refusal reason if any.

Remember across connections only what must be remembered. A token, a calibration the monitor handed out, a last-seen firmware version: into foogolf.store, read back in on_connect. Everything else is per connection and lives in a table you rebuild there.


11. Testing a driver

The emulator (the firmware in a browser) has no Bluetooth, so a driver cannot run there, and a real device cannot run a driver you have not published. The practical loop is:

  1. Offline, on your PC. Any Lua 5.4 (the lupa Python package, or the lua binary) with a short harness that defines foogolf.monitor to capture the manifest, stubs ble.write / ble.read / ble.mtu / ble.disconnect, monitor.* and foogolf.store / recall / store_keys to record their calls, and then feeds your hooks a scripted session: on_connect{ mtu = 247 }, the notifications your monitor sends (recorded with a phone BLE sniffer or taken from a protocol write-up), ticks at 50 ms. Assert the bytes your driver wrote and the status / shot calls it made. Count instructions with debug.sethook (count mode) and the heap with collectgarbage("count") while you are there: the device allows 100,000 instructions per hook call and 128 KB of heap. FooGolf’s harness for the R10 driver, foogolf-ota-cloud/extension-store/tests/test_garmin_r10.py, does exactly this (its prelude mocks every v2 call) and is a reasonable template; it also compiles the firmware’s own 32-bit Lua and replays the sessions on it, which is the only way to catch a signed-integer surprise before the device does.
  2. Publish privately. Create the extension on the store (section 12); a driver is installable by its code without any listing. Install it on your device, pair, and watch the status page: Steps shows every monitor.trace, the status line shows your monitor.status label and text, the Env log your monitor.log lines and any “Driver error”. Device status shows the driver’s heap peak.
  3. Hit shots in a GSPro or Browser sim session. The sim page’s flash proves nothing; the Env log line for the shot, and the shot in the simulator, do.

12. Publishing and installing

A driver is published like any extension (developer-guide.md section 7): sign in at the store, create a new extension from your file, write the description. The store compiles the file, finds foogolf.monitor{}, checks every manifest limit in section 2, and mints the five-character share code, which is the driver’s identity for life. On the store and on the website the kind shows as “launch monitor”.

Owners install it by code, or from Available once it is listed publicly (request a listing; the FooGolf team reviews it). A new version published under the same code reaches every device that has it at its next start-up or Check for updates; the device re-reads the manifest and, if the monitor is paired, reconnects under the new driver. The driver’s key/value store survives the update.

Everything in the file is public. There is no place for a secret in the file; a secret the owner holds goes through secret_prompt into the device-side store.


13. What a driver cannot do, and why

  • Reach the network, the screen or the speaker. A driver from a stranger must not be able to exfiltrate or to impersonate the device’s UI. Report through monitor.status, monitor.shot and monitor.battery; the device draws.
  • Feed a drill or a game. The device’s own sensors are the putting stroke; a monitor’s shots go to sim sessions only (a user decision, 2026-10-01). The call succeeds and the shot is dropped.
  • Run in the emulator. No Bluetooth there, by decision.
  • Scan, connect or choose a channel. The manifest describes the monitor and the device does the radio work. This is what keeps a broken driver from leaving the radio in a bad state.
  • Type a passkey the monitor displays, or pair out-of-band (section 3).
  • Encrypt or decrypt application payloads. There is no crypto binding, which is what keeps the Rapsodo MLM2PRO out of reach today; a request on the forum with the algorithm the monitor needs is how one would be added.

14. API history

FirmwareChange
0.8.630 (2026-09-30)API v1: the monitor kind, match / service / chars / subscribe / bond / pairing_hint, the four hooks, ble.write / mtu / disconnect, monitor.status / shot / trace / log / millis. The link ran only during a sim session.
0.8.631 (2026-10-01)Shots also reach the Browser sim.
0.8.635 (2026-10-06)A paired monitor is reconnected by address, never by scan.
0.8.636 (2026-10-07)The link is up whenever a monitor is paired. The battery level (Battery Service) on the status page.
0.8.637 (2026-10-07)wake_hint. The menu shows the driver’s name, not the advertised name.
0.8.638 (2026-10-07)The Launch monitor cell moved from the Attach row to the end of the Sim golf row.
2026-10-08The firmware’s native R10 code is gone; the Lua driver is the only R10 implementation.
2026-10-12 (0.8.658)The driver cells moved from the end of the Sim golf row to a Launch monitors row of their own (after PC sims and Browser sims); the row is shown only while a driver is installed.
2026-10-09 (0.8.647)One Sim golf cell per installed driver, named by the driver’s name; the pairing scan is filtered to the driver whose cell opened it; About shows the manifest’s about; pairing through another driver replaces the old pairing cleanly.
API v2, 2026-10-09foogolf.store / recall / store_keys for every extension kind (the driver scope is the link record; uninstall wipes). secret_prompt. Match rules: service and prefix (product deprecated), up to 8 rules. Up to 8 chars, a second service (services.aux), indications, ble.read. bond = "passkey" (display and numeric comparison). reconnect = "scan". A status text with every state; “Not ready” replaces “Waking” as the label. monitor.battery. Shots: hla optional, back_spin + side_spin, club fields individually optional, carry and total. The R10 driver v6 uses the prefix rule and status texts.

Rendered from golfclaude/docs/lua/monitor-drivers.md at 8ef01dd, 2026-10-26. The Markdown in the repository is the source; this page follows it on every push.