A Linux dev client for SuuntoPlus watch apps — build, install, list and pull over Bluetooth LE, with no cable and no vendor desktop software.
Suunto's official SuuntoPlus toolchain flashes apps from VS Code through a bundled binary
(SDSApplicationServer) that ships for Windows and macOS only. On Linux you can build a
.fea app but you cannot install it. zappctl closes that gap: it speaks the watch's own
Bluetooth "whiteboard" protocol directly, so the edit → build → install → verify loop runs entirely
on Linux.
$ ./bledeploy.sh my-app
[bledeploy] app=my-app appID=myapp01 variant=q
[bledeploy] building…
Build successful
[bledeploy] built myapp01-q.fea (53200 bytes)
[bledeploy] deploying over Bluetooth…
[scan] found via service UUID: Suunto Vertical 2 ABCD @ 7F:2C:19:A4:0B:63 (RSSI -54)
[connect] connected, MTU=127 (BLE chunks 124B)
[plan] 53,200 bytes -> 266 frames (139 init + 1 name + 118 stream + 8 finish)
[send] 260/266 crc=ok
[done] 266 frames sent, install CONFIRMED (status 200). App 'myapp01' is on the watch.Important
Unofficial and unaffiliated. Not made, endorsed, or supported by Suunto or Amer Sports. It talks to hardware you own, using a protocol recovered from a freely-licensed third-party app. Read docs/SAFETY.md before pointing it at a watch you care about.
Working and used daily for real app development — but it is a reverse-engineered tool with a narrow tested surface, not a product.
| Capability | State |
|---|---|
Install a .fea app |
✅ works, install confirmed by status code |
| List a directory | ✅ works |
| Download a file (incl. firmware syslogs) | ✅ works |
| Render a template at any display size | ✅ offline, no watch — see docs/RENDERING.md |
| Delete an app | ✅ over the cable only — see docs/CABLE.md |
| Workouts, settings, routes, firmware update | ❌ out of scope (host-side, not this channel) |
Deleting is the one thing Bluetooth cannot do: the whiteboard protocol has no delete verb at all.
sdsctl.mjs covers it through Suunto's own device daemon over USB — the only part of zappctl
that needs a cable and the vendor binary.
Tested on: Suunto Vertical 2 (NG3) and Suunto 9 Peak Pro (NG1), on Fedora with BlueZ 5.87.
Other SuuntoPlus watches (Race, Race S, Race 2, Ocean, Vertical) use the same protocol family and will most likely work, but are untested — reports welcome. See docs/VARIANTS.md for which display variant to build.
- Linux with BlueZ (developed on 5.87) and a working Bluetooth LE adapter
- Python 3.11+ (needs
tomllib) andbleak - Only for
bledeploy.sh: Node.js and Suunto's SuuntoPlus Editor extension, which provides thebuild-app.jscompiler. Not needed if you already have a built.fea. - Only for
sdsctl.mjs(deleting apps): Node.js 22+, a USB cable, and the same extension — it also bundles theSDSApplicationServerbinary, which needs a one-time setup on any Linux newer than Ubuntu 22.04. Steps in docs/CABLE.md.
git clone https://github.com/wylandplex/zappctl
cd zappctl
python3 -m venv .venv && ./.venv/bin/pip install -r requirements.txtOptionally create watches.toml (see watches.example.toml) — only useful when several
compatible watches are in range at once. No configuration is needed for a single watch.
1. Bond the watch once. BlueZ has to pair with it one time; after that the bond persists.
./pair-watch.sh scan # find the watch and its address
./pair-watch.sh init <MAC> # open a pairing session
./pair-watch.sh go # start pairing — a 6-digit code pops up on the watch
./pair-watch.sh 123456 # feed the code back
./pair-watch.sh done # ALWAYS finish with this, or the adapter scans foreverThere is no "pairing screen" to find: just open the watch's Connectivity menu and keep the display awake. Full walkthrough and failure modes in docs/PAIRING.md.
2. Free the Bluetooth link. The watch accepts exactly one central at a time. Turn the phone's Bluetooth off (or force-stop the Suunto app) for the whole session — otherwise every operation here fails to connect.
3. Verify the channel before writing anything.
./.venv/bin/python watchfs.py list b:/zappA list of installed apps means the protocol, the bond and the link are all good.
4. Install an app.
./bledeploy.sh path/to/my-app # build + install
./.venv/bin/python deploy.py my-app-q.fea # install a prebuilt .fea# build + install in one step (needs the vendor build-app.js)
./bledeploy.sh <app-folder> [--variant q] [--appid <id>] [--watch <alias>] [--dry-run]
# install a prebuilt .fea
python deploy.py <app.fea> [--watch <alias>] [--dry-run]
# for a watch that only advertises in short bursts: wait for it, then install at once
python catch-and-deploy.py <app.fea> --watch <alias> [--minutes 10]
# delete an app — USB cable, not Bluetooth (see docs/CABLE.md)
node sdsctl.mjs list # what is installed, with ids and names
node sdsctl.mjs delete climbl01 # asks first; verifies it is really gone
node sdsctl.mjs delete climbl01 --dry-run # resolve the target, send nothing
# filesystem
python watchfs.py list b:/zapp # installed apps
python watchfs.py list b:syslogs # firmware logs
python watchfs.py pull b:syslogs/systemevent_1297017.log --out fresh.log
# diagnostics (safe, read-only)
node test-sdsctl.mjs # offline: sdsctl against a fake SDS server
python probe.py --selftest-only # offline codec check, no watch needed
python probe.py --no-write # GATT/handshake sanity check
python probe_paths.py # which path spellings this watch accepts
python probe_dump.py # dump every reply frame
python probe_ngfs.py # read the device-assigned filesystem handle
# protocol RE (read docs/SAFETY.md first — some resources reboot the watch)
python wbexplore.py register "/Device/SystemEvent"
python wbinvoke.py --selftest
# see a template on every display before flashing it — no watch needed
node render-template.mjs --project ../my-app
node compare-sheet.mjs <beforeDir> <afterDir>Every Bluetooth tool takes the same watch-selection flags: --watch <alias> (from watches.toml),
--needle <name-substring>, --mac <address>. All are optional. sdsctl.mjs is the exception —
it talks over USB, where the watch is identified by --serial and only when more than one is
plugged in.
A status 200 on the install commit is not proof that the app is on the watch. This bit us badly: a wrong path spelling makes the upload land somewhere the watch ignores, and the install step still answers 200. Always confirm:
./.venv/bin/python watchfs.py list b:/zapp # look for <appid>.feaThe watch exposes a GATT service carrying a framed request/response protocol (Suunto's
"whiteboard"). zappctl brings a session up, streams the .fea into /zapp/<name>.zip on the
watch's filesystem, then commits it through /Plugin/Install.
Two details cost days of debugging and are worth knowing before you extend this:
- The install reference is device-assigned. Registering
/Plugin/Installreturns a handle that the following frames must address. Replaying a recorded value works on the watch it was recorded from and fails with 400 everywhere else. - Drive prefixes differ by generation. NG3 wants
b:/zapp; NG1 wants/zappand answers a well-formed but empty{}for the other spelling.
Full frame grammar, the capability map and the open RE targets: docs/PROTOCOL.md.
Connection problems are almost always one of four things: the phone still holds the link, an open desktop Bluetooth panel is scanning, the watch is asleep, or the code is looking for an address instead of a service UUID. Error-code fingerprints and fixes: docs/TROUBLESHOOTING.md.
The protocol comes from SyncFix by ~eppuh, released under
0BSD. That project did the hard reverse-engineering work; zappctl ports its Android
implementation to Python and extends it with filesystem access, multi-generation support, status
decoding and device-assigned handle resolution. A pinned copy lives in vendor/SyncFix
with provenance notes — thank you.
0BSD, matching the upstream protocol work: use it for anything, no attribution required.
Suunto, SuuntoPlus and Amer Sports are trademarks of their respective owners, used here only to describe what this tool is compatible with.