Skip to content

Smartwatch manager - #569

Open
dabear wants to merge 7 commits into
LoopKit:next-devfrom
dabear:smartwatch-manager-review
Open

dabear wants to merge 7 commits into
LoopKit:next-devfrom
dabear:smartwatch-manager-review

Conversation

@dabear

@dabear dabear commented Nov 2, 2026 •

Copy link
Copy Markdown

LoopWorkspace: watch plugins and the Pebble plugin

Adds the LupedSmartwatchKit Pebble plugin and points LoopKit and Loop at the forks until their PRs (LoopKit, Loop) are merged.

Commits

Commit What
061cd2a Loop: the bluetooth-peripheral background mode, with a commented-out switch in LoopConfigOverride.xcconfig
326a3aa LoopKit's plugin type and Loop's host; the LupedSmartwatchKit submodule, its projects in the workspace and its plugin bundle in the LoopWorkspace scheme; elbbepTransport 0.3.59 (Swift package, tagged releases), the Bluetooth link to the Pebble
f21b566 LoopKit, Loop and LupedSmartwatchKit: remote actions from the watch. Revert it, and the remote actions commits in LoopKit (82fe7c14), Loop (e3425e1d with its strings, 647c0dd4) and LupedSmartwatchKit (d66e597), to leave remote actions out. Since the next-dev merges, LoopKit's and Loop's reverts each have one small conflict; their PRs say how to resolve it.
9f53a6d Loop: the new strings in Loop's string catalog (47e7c4ab, and 647c0dd4 for remote actions), so they reach translation
bcb0504 Loop: make clear that Add Watch is for watches other than Apple Watch
c50e77f, 4243102 Merges of next-dev: #568 (kits on LoopKit's own value types, now merged) and #570 (async device deletion). 4243102 also moves LoopKit, Loop and LupedSmartwatchKit to their merges with next-dev, where SmartwatchManager.delete() is async like CGMManager and PumpManager.

Both versions, 326a3aa and f21b566, build and pass their tests (checked again on next-dev with #568 and #570); 9f53a6d adds only strings, and the catalog compiles.

Repositories: happy to move LupedSmartwatchKit, elbbepTransport and the watchface to the LoopKit organization, or another org you prefer.


Add a SmartwatchManager plugin type, with a Pebble plugin

Watches can now ship as plugins, like CGMs and pumps, instead of as code inside Loop. They appear under Settings → Devices → Add Watch and write to the device log. The first plugin, LupedSmartwatchKit, shows Loop's glucose, loop status and glucose alerts on a Pebble running the Luped CGM watchface.

Supported watches: Core Devices' Pebble Time 2 and Pebble Round 2. The watchface is built for those two platforms only (emery, gabbro). Older Pebbles, Apple Watch and Garmin aren't part of this PR; Apple Watch keeps Loop's existing app. Daily use is on a Time 2.

Using it: for people with diabetes and caregivers

This section is for the person wearing the watch, or a family member. It explains what the watch does; it isn't medical advice. Talk to your care team about your alert settings.

What it does: your Pebble shows the glucose Loop sees, and buzzes when Loop raises a glucose alert. It's an extra screen on your wrist: your iPhone still alarms as before, and you can't give insulin or enter carbs from the watch.

What you need:

  • a Pebble Time 2 or Pebble Round 2, paired in the Pebble app on the same iPhone as Loop;
  • the free Luped CGM watchface from the Pebble app's store, set as your watchface;
  • a Loop build that includes this change. Until it's part of a Loop release, that means building Loop yourself from these branches (how).

Setting it up: in Loop, go to Settings → Devices → Add Watch → Pebble and follow the steps. Add Watch is for watches other than Apple Watch: Loop's Apple Watch app needs no setup there, and is installed from the Watch app on your iPhone. Loop shows when it is waiting for the watch, when the watch is connected, and when the watch has received your first reading. If the watch is added in another diabetes app, remove it there first: the watch follows one app at a time.

On the watch:

  • your glucose, its trend arrow and the change since the last reading, with a graph of the last hours or, with the Large Glucose layout, just a big number;
  • a status line you choose, such as IOB and COB (insulin and carbs still active in your body);
  • a ring that shows when Loop last ran;
  • the ring turns red during a low or urgent low and amber during a high, and the watch buzzes each time Loop alerts. While Loop's alerts are muted, the watch neither buzzes nor changes colour, so a normal-looking ring doesn't mean there is no alert: check your iPhone.

When something is wrong:

  • ERR instead of a number: the watch has had no new reading for 15 minutes. It buzzes once when this starts.
  • A blue Bluetooth symbol: the watch isn't connected to your iPhone. It catches up when it reconnects.
  • "Waiting for CGM": Loop has no glucose to send yet.
  • A message asking you to restart the watchface: a different app on the phone started sending data. Restart the watchface, and keep the watch in one app.

Always check Loop on your iPhone, or your CGM, before acting on what the watch shows, especially when it shows ERR or the Bluetooth symbol.

Settings (Settings → Devices → Pebble): Layout, Status Line and Last Loop; Watch Alarms, which turns the buzz on or off (the ring still changes colour); Send Now; Diagnostics, with the watch's battery and a message log for troubleshooting; and Delete Pebble.

Screenshots

These are from an iPhone, with a Pebble Time 2 running Luped CGM, on these branches. The phone is set to Norwegian, so Loop's existing labels are Norwegian; the new texts have no translations yet.

The watch

Graph layout Large Glucose layout
Watch, Graph layout: glucose ring, IOB and COB, last loop, history graph Watch, Large Glucose layout

Adding a watch

No watch yet Settings → Devices Add Watch Before you start Set up
Devices with Add Watch: Tap here to set up a watch other than Apple Watch Devices with the Pebble row Add Watch picker: Pebble, Garmin coming soon, and the note that Apple Watch is not added here Setup: before you start Setup: reading shown on the watch

Next, not in this PR: a second plugin, GarminSmartwatchKit, built on the same plugin type from Trio's GarminManager (display only). My personal build lists it as coming soon and doesn't load it; the Add Watch screenshot above is from that build.

Pebble settings

Status and display Large Glucose Alarms and more Delete
Pebble settings: status and display Layout set to Large Glucose Watch Alarms, Diagnostics, Advanced, Delete Delete confirmation

Diagnostics

Watch info Communication log (also in Loop's device log)
Diagnostics: watch info Communication log with a decoded DATA message

Branches

All are named smartwatch-manager-review and sit on current next-dev. In each repo the feature comes first, then one commit with everything for remote actions (in Loop, plus one for their strings in the string catalog) (requests from the watch, off by default; the Pebble plugin doesn't use them). Reverting that commit in each repo leaves a working plugin type without remote actions. Both versions build and pass their tests.

Repo Plugin type: display and alerts Remote actions
LoopKit #684 27b16fd2; after merging next-dev, 0c5f5c38 (async delete()) 82fe7c14
Loop #2646 36bc837d (background mode), 19e32506, 47e7c4ab (strings), 097ada11; 6c4ee8ff merges next-dev e3425e1d, 647c0dd4 (strings)
LupedSmartwatchKit 60176ac; f84aa68 (async delete()) d66e597
LoopWorkspace (this PR) 061cd2a, 326a3aa; 4243102 merges next-dev f21b566; 9f53a6d pins Loop's strings commits

Related branches (no PR needed):

Repo Branch What
elbbepTransport main 22a71808, tag 0.3.59 The Bluetooth link to the Pebble, a Swift package used by the plugin
LupedPebbleWatchFace main ff0b1d15, Luped 0.1.22 The Luped CGM watchface, also in the Pebble app store

Not in this PR, for reference:

Repo Branch What
GarminSmartwatchKit main 51e69cff Garmin as a watch plugin (the "coming soon" screenshot), experimental
TrioSmartwatch smartwatch-manager 6693c43b Trio dev hosting the same plugins
TrioSmartwatch experimental/garmin-plugin-from-dev e3fc7a4e Trio with Garmin as a plugin, experimental
LoopKit trio-next-dev-smartwatch-manager 4a8bf252 The same plugin type on the LoopKit commit Trio dev pins

Dependencies

  • Until the LoopKit and Loop PRs are merged, LoopWorkspace points those submodules at the forks.
  • New in LoopWorkspace: the LupedSmartwatchKit submodule (iOS 17.6, like Loop), and through it elbbepTransport 0.3.59, a Swift package with tagged releases. It uses only CoreBluetooth and CryptoKit, has no third-party dependencies, and is MIT licensed, like the plugin.
  • On the phone: the Pebble app pairs the watch. Loop talks to the watchface directly over Bluetooth (PPoGATT), with no PebbleKit JS and no phone-side Pebble SDK.
  • LoopKit and Loop get no new dependencies.
  • Names: Luped is the watchface and plugin, elbbepTransport ("Pebble" backwards) the Bluetooth link, LoopPebble my Loop fork.

Why a new plugin type

  • Service has no device log, has no channel for the display unit or alerts, uploads incrementally from an anchor, and lives under Services.
  • StatefulPluggable has no UI, settings row or data delegate.
  • A watch is a device: like a CGM or pump it saves its state in rawState, logs to the device log and belongs under Devices. SmartwatchManager follows that model, shaped like Loop's Apple Watch contract (glucose, unit, prediction, loop state) plus host-issued alerts, because watches without iOS notification mirroring can't otherwise know when Loop alerts.

LoopKit: the protocols

SmartwatchManager.swift imports only Foundation and uses no other LoopKit type, so the same plugin builds against other apps' LoopKit forks (Trio's, for example). Every method is documented: when it is called, on which thread, and what the other side does.

public protocol SmartwatchManager: AnyObject {          // the plugin
    init?(rawState: RawStateValue); var rawState: RawStateValue { get }
    var pluginIdentifier: String { get }
    var smartwatchManagerDelegate: SmartwatchManagerDelegate? { get set }
    static var capabilities: SmartwatchCapabilities { get }   // what it can use
    var requestedCapabilities: SmartwatchCapabilities { get } // what it wants now
    @MainActor func watchDataDidUpdate(_ snapshot: SmartwatchStatusSnapshot)
    @MainActor func watchAlertDidIssue(_ alert: SmartwatchAlert)                  // .alerts
    @MainActor func watchAlertDidRetract(_ identifier: SmartwatchAlertIdentifier) // .alerts
    @MainActor func watchHostDidLog(_ message: String)        // host notes for the plugin's log
    @MainActor func watchHostDidReceiveURL(_ url: URL) -> Bool // companion-app callbacks
    func delete() async                               // like CGMManager and PumpManager
    var remoteActionLimits: SmartwatchRemoteActionLimits { get }  // remote actions commit
}

public protocol SmartwatchManagerDelegate: AnyObject {  // the host: Loop's DeviceDataManager
    func smartwatchManagerDidUpdateState(_ manager: SmartwatchManager)
    func smartwatchManagerWantsDeletion(_ manager: SmartwatchManager) async
    func smartwatchManagerRequestsRefresh(_ manager: SmartwatchManager)
    func smartwatchManager(_ manager: SmartwatchManager, didLogEvent event: SmartwatchDeviceLogEntry)
    func smartwatchManager(_ manager: SmartwatchManager, issueDeviceAlert alert: SmartwatchDeviceAlert)
    func smartwatchManager(_ manager: SmartwatchManager, retractDeviceAlert identifier: SmartwatchAlertIdentifier)
    // Remote actions commit: each refuses by default, so a host implements only what it supports.
    func smartwatchManager(_:requestBolus:) async throws -> Double
    func smartwatchManager(_:requestCarbEntry:) async throws -> Double
    func smartwatchManager(_:requestAlertMute:) async throws -> Date
    func smartwatchManager(_:requestOverride:) async throws
    func smartwatchManager(_:requestTempTarget:) async throws    // hosts with temp targets (Loop refuses)
}
  • Capabilities: glucose, history, prediction, loopStatus (IOB, COB, last loop, closed loop), therapy (basal, ISF, eventual glucose, correction range, active preset) and alerts. The remote actions commit adds remoteBolus, remoteCarbs, remoteMute, remoteOverride and remoteTempTarget. The host acts only on effectiveCapabilities (declared ∩ requested), so a plugin can't get what it didn't declare.
  • SmartwatchStatusSnapshot holds plain mg/dL values. The host fills only what the plugin asks for.
  • SmartwatchAlert carries glucoseAlertType (urgentLow, low, high, predictedLow, or nil). Each app names its alerts differently, so the host maps its own, and a watch can colour a low and a high differently without thresholds of its own.
  • Device alerts are about the watch itself, such as a missing companion app. They are at most time-sensitive; glucose alerts come only from the host.
  • UI: SmartwatchManagerUI, SmartwatchManagerUIPlugin and the bundle keys, as for CGM and pump plugins.

Loop: the host

  • PluginManager finds watch plugins. DeviceDataManager keeps one watch manager, saved as SmartwatchManagerState and kept if a build lacks the plugin. Settings → Devices gets a Watch row: Add Watch, a picker, the plugin's setup and settings.
  • SmartwatchManagerHost sends a snapshot as soon as a reading is stored and again when the loop's output changes, without resending unchanged ones, in HealthKit's stored glucose unit. The Apple Watch path is untouched.
  • Alerts: a new AlertForwarder hook on AlertManager passes Loop's immediate, unmuted glucose alerts and their retractions to the plugin.
    • The watch follows Loop's mute, critical alerts included.
    • Test alerts show on the watch and clear after 30 s.
    • Alarms that are off in the active profile, or all of them while the CGM raises its own, are cleared on the watch.
    • Each alert that isn't forwarded is logged with the reason.
  • Background mode: a plugin bundle can't declare background modes, so Loop/Info.plist lists bluetooth-peripheral under #if LOOP_BLUETOOTH_PERIPHERAL, through Info.plist preprocessing set in Loop.xcconfig. Clearing INFOPLIST_PREPROCESSOR_DEFINITIONS leaves it out. The built Info.plist is otherwise unchanged, and the mode does nothing unless a plugin publishes a Bluetooth service.

The Pebble plugin

  • LupedSmartwatchKit, laid out like G7SensorKit:
    • LupedSmartwatchKit: the manager;
    • LupedSmartwatchKitUI: setup and settings;
    • LupedSmartwatchPlugin: the .loopplugin.
  • It declares glucose, history, alerts, loopStatus and therapy, and requests only what the chosen display needs. It declares no remote actions.
  • Setup: a requirements checklist, then live progress until the watch acknowledges a reading.
  • Settings:
    • layout (Graph or Large Glucose), status line and last loop;
    • Watch Alarms;
    • Diagnostics: the watch's battery and counters, and a decoded communication log, which also goes to Loop's device log.
  • The watch colours its ring from Loop's active glucose alert and buzzes each time Loop fires or repeats one. It has no thresholds of its own, so it follows Loop's alert settings, including Delay 1st Alert for highs.
  • Messages: one per reading, sent once the loop's dose is stored; an alarm's reading goes at once. Details, and how the watch's requests are signed, are in the plugin README.

How the link works alongside the Pebble app

Loop doesn't replace the Pebble app; it adds its own channel to the watch, used only by the Luped watchface (elbbepTransport).

  1. The Pebble app owns the watch: pairing, the connection (encrypted with LE Secure Connections), notifications, the app store and updates. Loop never pairs or connects to the watch itself.
  2. Loop publishes its own PPoGATT service ("Pebble Protocol over GATT") on the iPhone, as the Pebble app does. The phone hosts the service and the watch connects to it as the client.
  3. The watch finds it on its existing connection, reads which watch app it is for (Luped's app UUID) and opens a separate app session for it, next to the Pebble app's own system session.
  4. Each session starts with a reset handshake; Loop then tells the watch it accepts 8 KB messages (otherwise 144 bytes).
  5. Loop and the watchface exchange AppMessages only: DATA from Loop, acknowledged by the watch (rejected while Luped isn't the running watchface), and refresh requests and hourly diagnostics from the watch. elbbepTransport handles sequencing, retransmission and resets.

What follows from this:

  • No PebbleKit and no Pebble SDK on the phone. The old PebbleKit iOS SDK doesn't recognise Core Devices watches.
  • Loop needs the bluetooth-peripheral background mode to keep its service up in the background.
  • One diabetes app at a time: two apps would publish the same service for the same watchface, and the watch follows whichever it connects to. Setup asks the user to remove the watch from other diabetes apps.
  • Loop's session can do only what PebbleOS allows third-party apps; system functions such as app logs stay with the Pebble app.
  • When the watch is out of range or the Pebble app isn't connected, Loop's link waits and sends the latest reading when the watch is back.

Remote actions: the gates

A request from the watch reaches Loop's dosing only if it passes every gate:

  1. The plugin declares the capability (remoteBolus, …), and the user hasn't turned it off in the plugin. Otherwise the host never receives the request. The Pebble plugin declares none.
  2. The plugin authenticates the request and passes each one on once. The Pebble plugin, for example, only accepts boluses signed on the watch with a per-session key; see its README.
  3. The user allows it in Loop: Settings → Watch Permissions has a toggle per action, off by default. The screen shows only when the plugin declares a remote action, and every toggle turns off when the watch is removed or replaced.
  4. LoopKit's shared, tested rules (SmartwatchRemoteActionPolicy.swift):
    • every request: confirmed on the watch at most 30 s ago, and the watch's clock no more than 5 s ahead;
    • bolus:
      • the context token from the latest snapshot, at most 15 min old;
      • a glucose reading in the last 15 min;
      • at most the maximum bolus, and at most the recommendation unless the user forced it;
      • one watch bolus per 15 min, at most 2 × the maximum bolus per 24 h;
      • a resend of a bolus still being delivered gets inProgress, not a second bolus;
    • carbs: at most Loop's maximum carb entry, one entry per 15 min, dated when the user confirmed them on the watch.
    • A plugin may declare its own limits (remoteActionLimits), for example a longer window for a watch app that can't send a token back. Loop's own checks below still apply.
  5. Loop's existing remote paths: the bolus goes through the remote bolus path (maximum bolus check, notification, upload), carbs and overrides through theirs, and mute through Loop's alert mute. Temp targets are refused.

Please note that I explicitly deny any remote actions for now in multiple gates:

  • The watchface on the watch does not expose any remote actions, for several reasons:
    • Watchfaces cannot authenticate or authorize end users in a good way.
    • Watchfaces cannot react to user input like touch or pressing physical buttons (the exception is shake detection, which can technically be used, but is a lousy user experience). This restriction is a PebbleOS limitation, and might be lifted later (I'm drafting PRs on this as well)
  • No remote capabilities are added to the plugin.
  • Remote capabilities are turned off in the UI by default, even if a plugin declares such a capability.

Questions for reviewers

  1. Background mode on by default. It's harmless without a watch plugin, and DIY builders get watch support without editing anything. Would you rather have it off in Loop and turned on in LoopWorkspace's LoopConfigOverride.xcconfig? This is to be compatible with (or not get in the way of) Tidepool Loop which may or may not want to have this background mode enabled.

  2. Remote actions: keep them, or should I revert the remote actions commits so this PR is display and alerts only?

  3. Repositories: I'm happy to move LupedSmartwatchKit, elbbepTransport and the Luped watchface to the LoopKit organization, or another org you prefer.

Bjørn Inge added 4 commits November 2, 2026 11:29
Watch plugins that publish a Bluetooth service need the bluetooth-peripheral background mode. It is on by default; uncommenting INFOPLIST_PREPROCESSOR_DEFINITIONS in LoopConfigOverride.xcconfig leaves it out. Loop points at the fork until its PR is merged.
LoopKit's SmartwatchManager plugin type and Loop's host, with the LupedSmartwatchKit submodule, its projects in the workspace and its plugin bundle in the LoopWorkspace scheme. elbbepTransport (Swift package, tagged releases) is the Bluetooth link to the Pebble. LoopKit and Loop point at the forks until their PRs are merged.
Bolus, carbs, alert mute and presets requested from the watch, each refused unless the plugin declares it and the user allows it in Watch Permissions. The Pebble plugin declares none yet. Revert this commit (and the matching commit in LoopKit, Loop and LupedSmartwatchKit) to leave remote actions out.
Bjørn Inge and others added 3 commits November 2, 2026 13:39
The base strings (47e7c4ab) and the remote action strings (647c0dd4), so they reach translation. To leave remote actions out, revert 647c0dd4 together with e3425e1d in Loop.
…LupedSmartwatchKit to their merges with next-dev

LoopKit#568 is merged, so its commits leave this PR. LoopKit's SmartwatchManager.delete() is async now, like CGMManager and PumpManager after #685, and the plugin follows.
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