⚠️ Work in progress — but broad. Most of what the Android/iOS Pebble companion apps do is already here: see What it does below. Some of it is verified on real hardware, some is written but not yet hardware-tested — docs/features.md marks which, and lists the remaining gaps. Expect occasional rough edges and breaking changes.
Built with heavy assistance from Claude (Anthropic's AI).
Headless Pebble smartwatch companion app / bridge for Linux / postmarketOS — a background daemon that bridges your Linux desktop to a Pebble watch over Bluetooth: BLE for modern watches (Pebble 2 / Time 2) and Bluetooth Classic for classic-era ones (Pebble Time / Time Steel …).
Stoandl is Bavarian dialect for "Steinchen" (little stone / pebble).
- Forwards desktop notifications (
org.freedesktop.Notifications) to the watch - Extends to any service (Matrix, Signal, SMS, "find my phone", …) via companion apps — small host-side programs, in any language, that drive watch notifications (with replies + actions) and optionally ship their own watchapp, no watchface coding required;
stoandl extinstalls and manages them - Manages the watch locker — list, launch, install (
.pbw) and remove apps & watchfaces - Backs up and restores your locker, app cache and PKJS/Clay settings
- Syncs weather to the watch's Weather app and as sunrise/sunset timeline pins (Open-Meteo — free, no account)
- Bridges desktop media players (MPRIS) to the watch's Music app — now-playing plus play/pause, next/previous and volume from the watch
- Syncs calendar events (and their reminders) to the watch's timeline as native pins — DE-agnostic (local
.ics, iCal feeds or CalDAV), reusing the calendars your desktop already keeps where it can - Configures the watch's advanced settings (quick-launch buttons, backlight, ambient-light, …) — the ones the official app exposes but the watch menus don't
- Flashes watch firmware — a local
.pbz, or (opt-in) the latest build for your watch's board, pulled from the right source automatically (PebbleOS GitHub releases for Core devices, cohorts.rebble.io for classic Pebbles), with an optional "update available" notification on the watch and your desktop - Installs watch language packs — a local
.pbl, or pick one for your watch from the built-in catalog (the official app's, bundled) and download+install it - Captures watch screenshots to a PNG —
stoandl screenshot— for sharing watchfaces and filing bug reports - Pulls watch logs and builds a support bundle —
stoandl logsdumps the watch's firmware logs;stoandl supportpackages them with the daemon log + watch info + redacted config into a.tar.gzfor bug reports - Resets the watch —
stoandl reset recoveryreboots it into recovery (PRF) firmware to un-brick a bad flash;stoandl reset factorywipes it back to out-of-box state - Reads the watch's battery level —
stoandl watch battery, and inline instoandl watch list - Reconnects automatically — after a watch disconnect, daemon restart, or coming back into range. BLE watches are handed to BlueZ's own background auto-connect (no polling); classic-era watches reconnect by paging the watch's fixed address (no advertising needed, so it survives airplane mode). Either way it links up on its own, with no restarts
- Runs as a background daemon with no UI
It also syncs health/activity data, captures custom-app datalogs, mutes notifications per app (including from the wrist), finds a misplaced watch, syncs time/timezone, exposes a Pebble-SDK developer connection, runs PKJS companion scripts + Clay config pages, and has phone-call support (caller ID, missed-call pins). → docs/features.md — full feature list, comparison with other companion apps, and what's not yet implemented.
BLE-native watches (Pebble 2 / Time 2) connect over BLE and work reliably. Classic-era watches (Pebble Time / Time Steel) are flaky over BLE — their reliable native transport is Bluetooth Classic (BR/EDR), which stoandl now supports as an experimental transport (see Bluetooth Classic below).
| Watch | Platform | Transport | Status |
|---|---|---|---|
| Pebble Time 2 | EMERY | BLE | ✅ Works |
| Pebble 2 | DIORITE | BLE | |
| Pebble Time / Time Steel | BASALT | Bluetooth Classic | ✅ Works — experimental, hardware-verified on a Time Steel (flaky over BLE) |
| Pebble Time Round | CHALK | Bluetooth Classic | |
| original Pebble / Pebble Steel | APLITE | Bluetooth Classic |
→ docs/devices.md — root causes, workarounds, transport details.
- JDK 25 to run. The Bluetooth Classic transport reaches the kernel through
java.lang.foreign, finalized in JDK 22. Make sure/usr/bin/java(andjavaonPATH) is JDK 25 on the machine that runs the daemon — e.g. Arch:archlinux-java set java-25-openjdk. - JDK 21 to build (in addition to 25). Gradle 8.14 cannot run on JDK 25, so the build runs on 21 while the Kotlin toolchain compiles against 25 — install both; see Build.
- BlueZ (bluetoothd running) — the default dual-mode adapter is fine; see Bluetooth setup
- D-Bus session bus with a notification daemon (dunst, mako, GNOME, etc.)
BLE is driven entirely through BlueZ over D-Bus, and the Bluetooth Classic transport reaches the kernel via
java.lang.foreign(no JNI/JNA) — so stoandl loads no glibc-only native library at runtime and runs on musl (postmarketOS / Alpine) as well as glibc distros. (The fat JAR still bundles kable's btleplug/JNA blobs as an unused transitive dependency — kable's types back the shared BLE abstraction, but its native backend is never instantiated on JVM/Linux, which uses BlueZ.) BLE is verified end-to-end (pair, connect, notifications) on a glibc laptop and a OnePlus 6 running postmarketOS; the FFM Classic path is musl-clean by construction but not yet hardware-verified on musl.
Most setups need no change. Leave the adapter in its default dual-mode (BR/EDR + LE): BLE-native watches (Time 2 / Pebble 2) on current firmware connect over LE, and classic-era watches (Time / Time Steel) need BR/EDR enabled for Bluetooth Classic.
LE-only mode (optional — BLE-native watches only). Some BLE-native Pebbles on old firmware advertise as dual-mode, so BlueZ tries a Classic connection that never falls back to LE. Forcing the adapter LE-only dodges that. Firmware v4.12.0+ fixes the advertising bug, so it's rarely needed — and don't use LE-only mode if you want a classic-era watch over Bluetooth Classic (that needs BR/EDR on).
# temporary (until reboot)
sudo btmgmt power off && sudo btmgmt bredr off && sudo btmgmt power onPersistent — /etc/bluetooth/main.conf:
[General]
ControllerMode = leThen sudo systemctl restart bluetooth. Note: this disables Bluetooth Classic for the whole adapter,
so an LE-only adapter can serve a BLE watch or a classic-era watch — not both at once. This trade-off
only matters for BLE-native watches on pre-4.12 firmware; on v4.12.0+ leave the adapter in its
default dual-mode and a single adapter serves both (the advertising bug that made LE-only necessary is
fixed, so you never enter this mode).
On first connection the watch shows a 6-digit code. stoandl auto-accepts on the Linux side — just confirm the code on the watch. Subsequent reconnects are automatic.
stoandl watch pair # pair a new watch (opens a ~2 min window; finds BLE and classic watches)
stoandl watch list # known watches, their connection state and battery level
stoandl watch battery # the connected watch's battery level
stoandl watch connect B349 # connect a specific known watch by name/substring (switches the active watch)
stoandl watch repair B349 # re-pair ONE watch by name/substring (forgets just it, then pairs)
stoandl watch unpair [name] # forget watches on this host — all of them, or just the named oneIf you forget the host on the watch (one-sided bond), stoandl notices the watch endlessly
re-connecting and dropping, and sends a notification with a one-tap Re-pair button — or run
stoandl watch repair <name>. pair never disturbs other watches, so it's safe with multiple Pebbles.
Conversely, if the pairing is removed on this computer (e.g. bluetoothctl remove, while the
watch still holds its side), stoandl forgets the now-unusable watch and notifies you that the only
way back is to unpair it on the watch too, then stoandl watch pair — a half-removed bond can't be
restored. (To forget a watch cleanly in the first place, use stoandl watch unpair, not bluetoothctl.)
Won't reconnect? The most common cause is another process running Bluetooth discovery — an open Bluetooth settings panel or pairing window (GNOME/KDE), or a stray
bluetoothctl scan on. Discovery monopolizes the adapter's single LE scanner, so BlueZ can't issue the watch's connection and it never links up. Check withbluetoothctl show | grep Discovering; if it'syesand you didn't start it, close the scanner — the watch reconnects within a second. stoandl logs a warning (and sends a desktop notification) when it detects this.
Experimental. Works and is hardware-verified on a Pebble Time Steel, but it's newer and less battle-tested than the BLE path, and off by default.
Classic-era Pebbles (Pebble Time / Time Steel — and, by class, the original Pebble / Steel) connect reliably only over Bluetooth Classic (BR/EDR, RFCOMM/SPP), their native transport. Their BLE path is a firmware-side race: the watch pairs once over BLE, then hands the host its Classic address and expects the persistent link over BR/EDR — so over BLE it often never reconnects. stoandl can now talk to these watches over Bluetooth Classic directly. The BLE path is untouched: BLE-native watches (Time 2 / Pebble 2) keep using BLE.
What works (hardware-verified on a Time Steel): discover → pair → connect → the full Pebble protocol →
automatic reconnect after the watch goes out of range or into airplane mode. The protocol layer is
transport-agnostic, so everything in What it does — notifications, the locker, health,
datalog, calendar, music, firmware, … — works the same over Classic. (The lone exception is the battery
read-out: it rides a BLE GATT service, so stoandl watch battery is unavailable over Classic.)
It's off until you opt in, in ~/.config/stoandl/stoandl.conf:
classic.discover = true # discover, pair and connect classic-era Pebbles automaticallyMake sure the adapter has BR/EDR enabled (the default — not LE-only mode; see Bluetooth setup). Then pair as usual:
stoandl watch pair # opens a pairing window; inquires for BLE and classic watches alike
# then confirm the matching 6-digit code ON THE WATCH — stoandl auto-confirms on the host sideWith classic.discover on, a known watch reconnects on its own afterwards: stoandl pages its fixed
address, so no advertising is needed and it survives airplane mode / out-of-range. A BR/EDR inquiry
only runs while a pairing window is open, so the radio stays quiet the rest of the time.
connect, unpair [name], repair and list all work for classic watches just like BLE ones —
unpair forgets a classic watch by its address even while it's connected. Only one watch is
connected at a time; stoandl watch connect <name> hands the active slot to another known watch. (Only
stoandl watch battery differs — see the battery note above.)
Pairing falls back to manual? Pairing a dual-mode watch occasionally creates an LE bond instead of the BR/EDR link key RFCOMM needs. If the watch pairs but won't connect, bond it explicitly with
btmgmt pair -t bredr <mac>, then (re)start the daemon.
The transport spans this repo and the libpebble3 fork (the BlueZ RFCOMM socket, the BR/EDR scanner and Classic pairing). → docs/devices.md for the full diagnosis and docs/configuration.md for the config keys.
Gradle 8.14 runs on JDK 21, but the Kotlin toolchain compiles libpebble3's java.lang.foreign
code against JDK 25 (auto-detected under /usr/lib/jvm) — both must be installed:
# Arch/Manjaro: sudo pacman -S jdk21-openjdk jdk-openjdk
# Debian/Ubuntu: sudo apt install openjdk-21-jdk openjdk-25-jdk
JAVA_HOME=/usr/lib/jvm/java-21-openjdk ./gradlew shadowJar # → build/libs/stoandl-<version>-all.jarIf your default java is already JDK 21, the JAVA_HOME= prefix is unnecessary. (install.sh
applies this pin for you via BUILD_JAVA_HOME.)
The daemon requires a JDK 25 runtime (see Requirements). gradlew itself still
needs JDK 21, but the run task launches the app on the JDK 25 toolchain:
JAVA_HOME=/usr/lib/jvm/java-21-openjdk ./gradlew runOr with the fat JAR (here java must be JDK 25):
java -jar build/libs/stoandl-*-all.jarWith the daemon running and a watch connected, the stoandl CLI talks to it over D-Bus:
stoandl apps # list watchfaces and apps in the locker
stoandl apps launch <name|uuid> # launch an app or watchface on the watch
stoandl apps remove <name|uuid> # uninstall an app or watchface from the locker
stoandl apps install app.pbw # install a .pbw onto the connected watch
stoandl config [app] # open a PKJS app's Clay config page (launches it if needed)
stoandl settings [filter] # list the watch's advanced settings (quick-launch, backlight, …)
stoandl settings set <id> <v> # set one, e.g. settings set lightAmbientThreshold 200
stoandl version # version of the running daemon (and this CLI)launch/remove match a watch app by UUID or by (case-insensitive) name — exact name first,
then substring. If the name is ambiguous the command lists the candidates so you can pick the
UUID. apps flags each entry as active (current watchface), sideloaded, config
(has a settings page) or system. System apps cannot be removed.
Plug in your own integrations — Matrix, Signal, Discord, SMS, "find my phone", anything — host-side,
the way Android PebbleKit companion apps work. An extension is a small program in any language that
stoandl spawns and supervises, talking newline-delimited JSON-RPC over stdio. It can push notifications
to the watch (with named actions and canned replies), receive a callback when you act on the watch and
send the reply back through that service's own account, and/or be a PebbleKit-style companion to a
watchapp UUID it ships (AppMessage send/receive, launch, .pbw install) — so no watchapp is required,
but one is supported. A ~40-line per-language helper (e.g. stoandl_ext.py) is shipped as a template.
stoandl ext install matrix.tar.gz # extract to ~/.config/stoandl/ext/, sideload any bundled .pbw,
# enable + hotplug-start (no daemon restart)
stoandl ext list # installed extensions + enabled/running state
stoandl ext enable <name> # add to the run-list and start (disable to stop + drop)
stoandl ext restart <name> # restart one after editing it
stoandl ext uninstall <name> # stop, remove its dir, and remove any watchapp it installedExtensions run as you, like any script — there's no sandbox. The default entry point is <name>.py
under ~/.config/stoandl/ext/<name>/; per-extension settings live in that dir's config file (or
override with extension.<name>.cmd / a bundled manifest.json). examples/extensions/ vendors working
examples, each in its own repo (submodule): a full Matrix client —
messages on the wrist + canned replies + on-demand image previews, end-to-end-encrypted — and
find-my-phone. Set
extensions.enabled to the run-list, or just use stoandl ext.
→ docs/extensions.md — the wire protocol, the helper API, and how to write one.
stoandl keeps your whole setup — the locker, the cached .pbw binaries, app order, the active
watchface, and PKJS/Clay settings — under ~/.config/stoandl/. None of it is tied to a specific
watch, so it survives unpairing: when you re-pair a watch, libpebble3 re-syncs the locker back
onto it automatically. Back that state up (or move it to another machine) with:
stoandl backup [out.tar.gz] # default: ./stoandl-backup-<timestamp>.tar.gz
stoandl restore <in.tar.gz> # stop the daemon first; --force to overrideFor a guaranteed-consistent snapshot, stop the daemon before backup (it's safe to run live,
but the SQLite DB is captured mid-write). restore moves any existing ~/.config/stoandl/
aside to stoandl.old-<timestamp> rather than overwriting it, and refuses to run while the
daemon is up.
Note: settings a watchapp writes directly on the watch (the C persist_write API, as opposed
to Clay/PKJS config) live only on the watch and are not part of this backup.
stoandl syncs upcoming calendar events to the watch's timeline as native pins — DE-agnostically
from local .ics files (reusing e.g. Calindori on Plasma Mobile via calendar.discover), or from
iCal feed URLs / CalDAV. It's off until you add a source. Manage sources from the GUI
(Settings → Calendars, which groups a CalDAV account's calendars under it) or the CLI; changes
apply live (no restart). CalDAV passwords are never stored in stoandl.conf — they go in the
system keyring (org.freedesktop.secrets) when one is unlocked, otherwise a 0600 secrets file beside
the config (excluded from backups).
stoandl calendar list # synced calendars + enabled state
stoandl calendar sources # editable sources (CalDAV accounts / iCal feeds / .ics) + ids
stoandl calendar add caldav https://dav.example.com/alice/ alice # prompts (no echo) for the password
stoandl calendar passwd <id> # change a stored CalDAV password
stoandl calendar remove <id> # drop a source (and its stored password)
stoandl calendar disable <id|name> # stop syncing one calendar (enable to undo)
stoandl calendar sync # force a re-read now
stoandl calendar dump <file|url> # parse + print events offline (no daemon/watch needed)→ docs/configuration.md#calendar — sources, the reuse-your-DE story (and why GNOME/KDE online accounts need an iCal/CalDAV URL for now), recurrence/timezone handling, and the egress note.
Flash watch firmware. A local bundle works offline with no setup; libpebble3 checks it against the connected watch (board, CRC, slot) and refuses a mismatched bundle before sending anything.
stoandl firmware sideload <file.pbz> # flash a local firmware bundle (shows a progress bar)
stoandl firmware check # is newer firmware available for this watch?
stoandl firmware update # download the matching build and flash it
stoandl firmware status # current firmware-update statecheck/update fetch firmware online for the connected watch, choosing the source by its generation:
Core devices (Pebble 2 Duo / Pebble Time 2) from the PebbleOS GitHub releases (default
coredevices/PebbleOS), classic Pebbles (Pebble Time / Time Steel …) from cohorts.rebble.io.
Each is opt-in egress, off until you enable it (firmware.github / firmware.cohorts). The watch's
board maps exactly to the right bundle, so the build is picked automatically; with firmware.notify
(on by default once a source is) stoandl also pushes an "update available" notification — to both the
watch and your desktop — each with an Update button that flashes it.
Flashing is the riskiest thing stoandl does. It's guarded by the pre-flash safety checks and Pebble's recovery firmware, but flash on charger and keep the watch in range.
→ docs/configuration.md#firmware-updates
Install a firmware language pack onto the watch — changing its notification/UI language and loading the
fonts a script needs (Cyrillic, CJK, Burmese, Hebrew, …). A local .pbl works offline; the built-in
catalog (the official app's manifest, bundled) lets you pick one for your watch and download it.
stoandl language list # packs for the connected watch (installed one marked *); the full catalog if none
stoandl language sideload pack.pbl # install a local .pbl (offline)
stoandl language install de_DE # pick from the catalog and download+install (needs language.download)
stoandl language status # current install stateinstall downloads from Rebble's CDN (or a community GitHub repo for packs like Japanese/Hebrew) — opt-in
egress, off until you set language.download = true. list and sideload never touch the network.
Core devices (Pebble 2 Duo / Time 2) share the Diorite (silk) packs; classic Pebbles use their own board.
→ docs/configuration.md#language-packs
Capture the watch's current screen to a PNG on the host — handy for sharing watchfaces or filing bug reports.
stoandl screenshot # → ./pebble-screenshot-<time>.png
stoandl screenshot watchface.png # → a name you choose
stoandl screenshot ~/Pictures/ # → a timestamped PNG in that directoryWorks on colour (Basalt/Chalk/Emery) and 1-bit classic Pebbles alike. Purely local — no network, no setup. The capture takes a couple of seconds.
Pull the watch's own firmware logs, or package a full diagnostic bundle for a bug report.
stoandl logs # → ./pebble-logs-<time>.txt (the watch's firmware log)
stoandl logs /tmp/watch.txt # a name you choose
stoandl support # → ./stoandl-support-<time>.tar.gz
stoandl support --coredump # also pull a coredump off the watch, if it has oneThe support bundle gathers the watch's firmware logs and metadata, the daemon log (/tmp/stoandl*.log),
the stoandl version, and your stoandl.conf with secrets redacted (CalDAV passwords and any
credentials/tokens in URLs). It's resilient: even with no daemon running or no watch connected it still
collects whatever it can, noting what's missing in bundle-notes.txt. Purely local — nothing is uploaded.
Review the archive before sharing: the watch logs can still contain personal data.
Reset the connected watch — the companion to the firmware tooling, for un-bricking a bad flash or wiping the watch for handoff.
stoandl reset recovery # reboot the watch into recovery (PRF) firmware
stoandl reset factory # wipe the watch back to out-of-box state (asks to confirm)
stoandl reset factory --yes # …skip the confirmation promptreset recovery is recoverable — from PRF, reflash a normal firmware with stoandl firmware sideload <file.pbz>.
reset factory is irreversible: it erases all apps, settings and the host pairing (you'll re-pair
afterwards), so the CLI requires a typed yes unless you pass --yes. Both are local — nothing is
uploaded, and there's no config to set.
Logs go to /tmp/stoandl.log (rolling, 5 MB × 3) and stdout. Default level is INFO.
For full BLE/protocol traces, set STOANDL_LOG=DEBUG:
STOANDL_LOG=DEBUG ./gradlew runFor the systemd service, add to the unit or a drop-in:
[Service]
Environment=STOANDL_LOG=DEBUGThe install.sh script builds the fat JAR, installs the service + stoandl CLI,
and (re)starts it:
./install.sh # build + install on this machine
./install.sh -d # also enable DEBUG logging
./install.sh --remote user@host # build locally, scp + install over SSH--remote is handy for a phone (e.g. postmarketOS) where building on-device is
slow — it builds the architecture-independent JAR on your fast machine and pushes
it. (It uses ssh -t so sudo can prompt for a password.)
The installed service +
stoandlwrapper call/usr/bin/java/java, which must be JDK 25 on the target (see Requirements). The build runs on JDK 21;install.shpins that automatically viaBUILD_JAVA_HOME(default/usr/lib/jvm/java-21-openjdk). On Alpine / postmarketOS theAPKBUILDhandles both JDKs and writes absolute JDK-25 paths into the unit.
Or install manually:
sudo install -Dm644 build/libs/stoandl-*-all.jar /usr/lib/stoandl/stoandl.jar
sudo install -Dm644 packaging/stoandl.service /usr/lib/systemd/user/stoandl.service
systemctl --user daemon-reload
systemctl --user enable --now stoandlDepends on a patched fork of coredevices/libpebble3
(the stoandl branch at yoxcu/libpebble3), included as a git
submodule at libs/libpebble3. Run git submodule update --init --recursive after cloning. Wired
via Gradle composite build — no separate Maven publish needed.