Skip to content

Draft: Add Pebble Watches - #2568

Draft
dabear wants to merge 12 commits into
LoopKit:next-devfrom
bjorninge-no-personal:luped-pebble
Draft

dabear wants to merge 12 commits into
LoopKit:next-devfrom
bjorninge-no-personal:luped-pebble

Conversation

@dabear

@dabear dabear commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Pebble Watch support

watch-luped-glucose IMG_1053 IMG_1052 IMG_1065 IMG_1051

Summary

Adds an opt-in transport that sends Loop's CGM readings and glucose alert settings to the Luped watchface on Core Devices Pebble watches (Pebble Time 2 and Pebble Round 2 since it requires "Alloy" only present on these watches), and a Settings → Devices → Pebble device to manage it. It is compiled only when LUPED_PEBBLE is set.

The transport and Luped logic live in the elbbepTransport Swift package, so Trio and other apps can share them later. Loop keeps a thin adapter and the settings screen. This is direct push over bluetooth.

Since communication to Pebble watches is watchface dependant, Loop will in this version only talk to my Luped watchface. The source for this Pebble watchface is open source and available at (https://github.com/dabear/CrimsonBearPebbleWatchFace/tree/main/variants/luped). If a developer or experienced user wants to do any changes to the luped watchface, you can either build your own based on available sources, or change uuid under Loop->Devices->Pebble->Advanced to talk to entirely different watchface, as long as the luped protocol is used.

These watches typically have more than 10 days wear time per charge. This could also be improved in the future, but requires some modifications to the now-opensource PebbleOS. Pending..

How it reaches the watch

Loop publishes its own PebbleOS PPoGATT ("Pebble Protocol over GATT") service with CoreBluetooth. The watch stays paired with and connected to the Pebble app, which keeps its own session for notifications, PebbleKit JS and other watchfaces. PebbleOS discovers Loop's service on the same Bluetooth connection, reads the app UUID from its meta characteristic, and opens a second session that carries only Luped AppMessages.

The legacy PebbleKit iOS SDK is not used: its watch discovery does not recognize Core Devices watches, so it never reports a connected watch.

Code layout

  • ElbbepTransport (package): a generic link for any JS-free watch app. It runs the PPoGATT service and session, Pebble Protocol framing, the phone-version reply, AppMessage encoding/decoding and ACKs, and app run-state commands.
  • LupedKit (package): Luped's message keys and DATA payload, the alarm mask and alert-mute handling, the refresh and diagnostics commands, delivery with retry and pausing, link status, watch diagnostics, the communication log, and the lupedPebble… settings.
  • LupedPebbleTransport.swift (Loop): an adapter that supplies stored CGM readings in Loop's display unit, the active glucose alert profile and the alert mute, and asks LupedKit to re-send when any of them change.
  • LupedWatchSettingsView.swift (Loop): the settings screen.
  • Scripts/luped-background-mode.sh (Loop): a build phase that adds the bluetooth-peripheral background mode, only when LUPED_PEBBLE is set.

Enable

  1. Add LUPED_PEBBLE to the Features line in LoopConfigOverride.xcconfig:

    SWIFT_ACTIVE_COMPILATION_CONDITIONS = $(inherited) ... LUPED_PEBBLE
    
  2. Pair the watch with the Pebble app and select the Luped watchface.

Without the flag, the Luped sources compile to nothing, the Pebble device does not appear, and no background mode is added. The elbbepTransport package is still resolved and linked statically, but no code uses it.

Settings → Devices → Pebble

Pebble appears as a device next to the pump and CGM. It is off until Add Smartwatch is tapped. Delete Pebble (with confirmation) ends the watch session, releases the Bluetooth peripheral manager, stops building payloads, resets the Pebble settings and clears the log.

  • Status: link state (Bluetooth unavailable, waiting for the watch, connecting, connected, paused), the last delivered reading with its round trip, and the next retry. Send Now pushes the current reading immediately.
  • Display → Full-Screen Ring (off by default): a large glucose ring with the reading time inside it instead of the ring and trend graph.
  • Alarms → Watch Alarms (on by default): watch vibration using Loop's active glucose alert profile, including its thresholds, per-zone switches and repeat intervals. Glucose Alert Settings opens Loop's alert screen. Turning it off silences the watch only; Loop's alerts are unaffected.
  • Alarms → Follow Loop Alert Mute (on by default): while Loop's alerts are muted, the watch's glucose alarms are off too. Loop alone owns the mute; when it ends or is cancelled, Loop re-sends the reading with the profile's alarm mask.
  • Diagnostics: battery and counters from Luped's diagnostics snapshot, fetched only when Refresh Watch Info is tapped, and the Communication Log. The log records every event between Loop and the watch since Loop started: session setup, handshakes, each push with a field-by-field decode, the watch's ACK/NACK with round trip, refresh requests, retries, settings changes, and why watch alarms are on, off or muted. The last 500 entries are kept in memory, and Share exports them as text.
  • Advanced → Watchface UUID (default bf32924c-4839-449e-9e56-ee190249acb2): the app UUID that Loop's service and AppMessages use.

Settings are stored in standard user defaults (lupedPebbleEnabled, lupedPebbleAlarmsEnabled, lupedPebbleFollowAlertMute, lupedPebbleFullScreen, lupedPebbleWatchfaceUUID) and apply within half a second.

Delivery

  • After Loop persists new CGM samples, it sends:
    • the latest 12 CGM readings from the last two hours, excluding user-entered and display-only samples
    • the current value, delta and trend
    • the display unit
    • the reading time
    • the alert settings
  • The trend uses the CGM's reported trend when it has one; otherwise it is computed from the last two readings. Values are rounded to the watch's precision: whole mg/dL, or one decimal for mmol/L.
  • The latest payload is kept, so a new watch session or Luped's startup refresh request re-sends it.
  • A rejected or unacknowledged send is retried after 30 s, 1, 2, 4 and 5 minutes. If none of those is accepted, Loop pauses sending until Luped starts, a new session opens, or Send Now is tapped.
  • Loop answers Alloy's startup handshake (AppMessage key 15025), which keeps a watchface's outbox closed until the phone replies.
  • On each session, Loop answers PebbleOS's phone-version request with the 8 KiB AppMessage capability. Without it, Luped's inbox is limited to 144 bytes.
  • Alloy disables a watchface's outbox whenever Loop's session opens or closes. When Luped has not messaged Loop since the session opened, a watch-info request therefore first restarts Luped (app run-state STOP, then RUN). This switches the watch to Luped if another face was active.

Safety notes

  • Watch alarms are a convenience on top of Loop's alerts, not a replacement. The watch alarms only when it receives a reading from Loop, so it has no glucose alarms when the phone is out of range.
  • Luped (0.1.1 and later) always gives one short buzz when its latest reading becomes 15 minutes old, regardless of the alarm settings or Loop's mute.
  • When Loop leaves glucose alerts to the CGM's own alerts, watch alarms are off. When Loop's alert settings are unavailable, the watch also gets fixed display thresholds (55/80/200 mg/dL).
  • Only watchfaces without PebbleKit JS can use this session. PebbleOS routes messages from JavaScript-enabled apps to the Pebble app instead.

Testing

  • Package: swift test in elbbepTransport passes 16 tests. They cover Pebble Protocol framing, AppMessage encoding and decoding, the Luped payload format, mmol/L rounding, the trend fallback, the alarm mask, mute handling and the alarm-off reasons.
  • Hardware: built and run on an iPhone with a Pebble Time 2 running Luped 0.1.1. The device logs showed:
    • the session opening and readings acknowledged in about 20–30 ms
    • mute and unmute sending mask 0, then the profile mask again
    • Watch Alarms off and on sending the right mask and log reason
    • pump and CGM restoring normally
  • Not tested: Loop's unit tests have not been run against this branch, and the mute ending on its own timer has not been observed on hardware.

Before merging

  • Rebase onto current next-dev; the branch is 4 commits behind.
  • Decide how to pin elbbepTransport: the branch tracks main, and a tag or revision pin would make builds reproducible.

Bjørn Inge Berg and others added 12 commits October 8, 2026 23:55
Loop publishes its own PebbleOS PPoGATT service with CoreBluetooth and
pushes CGM readings to the Luped watchface, side by side with the Pebble
app. The legacy PebbleKit iOS SDK is dropped: its discovery never
recognizes Core Devices watches.

- Answers the phone-version request with 8 KiB AppMessage support and
  Alloy's startup ready handshake; retries rejected pushes with backoff.
- Sends values in Loop's display unit at display precision, and mirrors
  the active glucose alert profile (thresholds, per-zone switches,
  repeat intervals).
- Settings → Devices → Pebble: Send to Pebble, Watch Alarms, watchface
  UUID, and a communication log with decoded payloads and watch replies.

Compiled only with LUPED_PEBBLE; builds on next-dev's GlucoseAlertManager.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Settings → Devices → Pebble → Full-Screen Ring sends DATA index 13.
- Rename the "Luped PebbleKit" build phase and script to "Luped
  Background Mode" (luped-background-mode.sh); it only adds the
  bluetooth-peripheral background mode.
- Add the Pebble settings strings to Localizable.xcstrings (manual
  extraction, since they compile only with LUPED_PEBBLE).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Settings → Devices → Pebble shows live link status, the last delivered
  reading with its round trip, and the next retry; Send Now pushes the
  current reading.
- Watch section shows battery and counters from Luped's diagnostics
  snapshot. AppMessage tuples are parsed by key so diagnostics replies,
  Alloy's ready handshake, and refresh commands are told apart.
- Explain missing diagnostics replies: Alloy disables a watchface's outbox
  whenever Loop's session reconnects, until the watchface restarts.
- After the full retry backoff (about 12 minutes) without an ACK, pause
  sending, including new readings, until Luped starts, a new session
  opens, or Send Now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Pebble screen requested diagnostics on every onAppear, including each
return from the communication log. Limit automatic requests to one a
minute, skip them while the watch is known to be unable to reply, and log
what triggered each request. Refresh Watch Info always sends.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Drop the automatic diagnostics request on screen appearance. When Luped has
not messaged Loop since the session opened, its Alloy outbox is disabled, so
Refresh Watch Info first restarts the watchface over the app run-state
endpoint (STOP, then RUN) and sends the request after Luped's ready
handshake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pebble is no longer set up by default: Devices shows Add Smartwatch, and the
Pebble screen ends with Delete Pebble (with confirmation), which stops the
transport and resets its settings. The Send to Pebble switch is gone.

The Pebble screen groups Status (with Send Now), Display, and Alarms (with a
link to Glucose Alert Settings); watch info and the communication log move
to Diagnostics, and the watchface UUID to Advanced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follow Loop Alert Mute (on by default) sends Luped's alarm mask as 0
while Loop's alerts are muted and restores it when the mute ends or is
cancelled. Loop owns the mute state; Luped 0.1.1 keeps its no-recent-data
buzz regardless. The communication log records each change in why watch
alarms are on, off or muted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
PebbleTransport is a generic link for any JS-free watch app: the PPoGATT
service and session, Pebble Protocol framing, the phone-version reply,
AppMessage encoding, decoding and ACKs, and app run-state commands. It
reports link state, session events, messages, replies and log lines to a
delegate.

LupedPebbleTransport builds on it with Luped's keys and DATA payload,
refresh and diagnostics, delivery with retry and pausing, and alarm
reasons. It mirrors the link state into the status row and writes the
generic log lines to the communication log unchanged. No behavior change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The generic Pebble transport and the Luped layer (payload, alarm and mute
handling, delivery, commands, status, diagnostics, communication log and
settings) now live in https://github.com/dabear/elbbepTransport so Trio
can share them. Loop links the LupedKit product and keeps a thin adapter
that supplies stored CGM readings in the display unit, the active glucose
alert profile and the alert mute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its content moves to the pull request description; the transport and
Luped protocol are documented in the elbbepTransport package.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dabear
dabear marked this pull request as draft October 13, 2026 06:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant