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.
| Monitor | Match rule | Bond | Persistent state | ble.read | Indications | hla | Spin as | Note |
|---|---|---|---|---|---|---|---|---|
| Garmin Approach R10 | manufacturer 0x0087 + prefix, or name | Just Works | no | no | no | yes | total + axis | garmin-r10-protocol.md |
| Rapsodo MLM2PRO | service UUID or name | none (app-level auth) | yes - the user token (secret_prompt) and an issued key | likely | no | yes | back + side | encrypted payloads: needs a crypto binding that does not exist yet (section 13) |
| Swing Caddie SC300 / SC300i | name prefix | none | no | no | no | absent | total only | one length-prefixed frame per notification |
| PRGR / Voice Caddie SC4-class | name prefix | none | no | no | no | varies | varies | |
| Square Golf | service UUID | Just Works | no | maybe | no | yes | back + side | |
| FlightScope Mevo (BLE models) | service UUID | none | maybe | likely | maybe | yes | back + 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
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 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:
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 everysubscribekey is subscribed. Start your handshake here.info.mtuis the negotiated ATT MTU (23 until exchanged; the device asks for the maximum, so expect up to 247);info.nameis 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 namedkey, as a Lua string of raw bytes (#bytesis 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;msis the device’s uptime in milliseconds (the same clock asmonitor.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, orfoogolf.storesomething 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.
modeis"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() - 3is 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
keythat is not in your manifest raises a Lua error (a driver error); a write while not connected returnsfalse, "not connected". Any other failure returnsfalsewith 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.
| state | what the owner should understand | label |
|---|---|---|
"handshake" | connected, the driver is still setting up | Connecting |
"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 reported | Ready |
"busy" | a swing is being measured or processed | Shot |
"error" | a fault; the text says what | Error |
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).
| field | unit | required | accepted range |
|---|---|---|---|
ball_speed | mph | yes | 1 to 250 |
vla | degrees, vertical launch angle, positive up | yes | -20 to 90 |
hla | degrees, horizontal launch angle, positive = right | no, default 0 | -60 to 60 |
total_spin | rpm | no | 0 to 20000 |
spin_axis | degrees, GSPro’s sign (positive tilts the spin axis right, a fade for a right-hander) | no | -90 to 90 |
back_spin | rpm, the alternative to the pair above | no | 0 to 20000 |
side_spin | rpm, positive = fade for a right-hander (the same sense as a positive spin_axis) | no | -20000 to 20000 |
club_speed | mph; a value above 0 means club data is present | no | 0 to 200 |
attack_angle | degrees, positive up | no; default 0 when club data is present | -30 to 30 |
face_to_target | degrees, positive = open to the right | no; default 0 when club data is present | -45 to 45 |
path | degrees, positive = in-to-out for a right-hander | no; default 0 when club data is present | -45 to 45 |
carry | yards, the monitor’s own carry | no | 0 to 500 |
total | yards, the monitor’s own total | no | 0 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.
storereturnsfalse, "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_promptin the manifest, the device saves what the owner typed as the key"secret"after pairing. Read it withfoogolf.recall("secret")inon_connect;nilmeans 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.
storechanges a table in RAM and the device flushes it to flash a few seconds later, at every disconnect, and before a restart. A driver callingstorein 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
| Lua | 5.4, 32-bit integers and single-precision floats (the firmware’s LUA_32BITS build) |
| Libraries | string (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 available | io, os, require, load, loadstring, dofile, debug, package, print, every other extension module (canvas, hud, audio, net, players, settings, device, timer, util) |
| Heap | 128 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. |
| Instructions | 100,000 per hook call |
| Source | 64 KB |
| Notification | 247 bytes each, a queue of 32 |
ble.read | 256 bytes, 3 s |
| Shot queue | 4 |
| Store | 2048 bytes, 32 keys, values of 512 bytes |
| Log | one line per 2 s |
| Trace | the 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:
- Offline, on your PC. Any Lua 5.4 (the
lupaPython package, or theluabinary) with a short harness that definesfoogolf.monitorto capture the manifest, stubsble.write/ble.read/ble.mtu/ble.disconnect,monitor.*andfoogolf.store/recall/store_keysto 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 withdebug.sethook(count mode) and the heap withcollectgarbage("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. - 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 yourmonitor.statuslabel and text, the Env log yourmonitor.loglines and any “Driver error”. Device status shows the driver’s heap peak. - 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.shotandmonitor.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
| Firmware | Change |
|---|---|
| 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-08 | The 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-09 | foogolf.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.