Skip to content

Sport Mode, part 3/5: G7SensorKit — watchOS target + radio acquisition fixes - #65

Draft
ps2 wants to merge 11 commits into
next-devfrom
sportmode/g7sensorkit-upstream
Draft

Sport Mode, part 3/5: G7SensorKit — watchOS target + radio acquisition fixes#65
ps2 wants to merge 11 commits into
next-devfrom
sportmode/g7sensorkit-upstream

Conversation

@ps2

@ps2 ps2 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

What this is

The G7SensorKit slice of Jeremy Barnum's Sport Mode line (standalone watch). Part 3 of a
bottom-up landing: LoopKit (#612) → RileyLinkKit (#18) / G7SensorKit → OmnipodKit → Loop →
workspace pin bump.

Draft: design review wanted before this goes near next-dev.

Eleven commits, rebased onto current next-dev (fb87aa9). One real conflict, resolved — see
below.

The changes, in two groups

1. watchOS support (4 commits) — additive

  • New G7SensorKit-watchOS framework target and its shared scheme.
  • watchOS source guards and a transport seam.
  • Deployment target 10.6, to match LoopKit.
  • Pins the iOS target's SDKROOT. Without it, an implicit-dependency hijack lets the watchOS
    target satisfy an iOS dependency — worth a look, since it touches the existing iOS target.

2. G7 radio acquisition work (7 commits) — behavioral, iOS included

This is the half that deserves scrutiny, because it changes stock iOS G7 behavior:

  • "Fix the 20–40 minute acquisition outage: arm the scan whenever not connected." A real
    acquisition bug with a real fix, and the most valuable commit here for non-watch users.
  • G7 radio census — makes the acquisition mechanism observable; exposes sensor sightings as a
    signal rather than prose, and adds [domain#code] on connect failures.
  • Watchdog delivery baseline seeding, so the first check isn't infinity.
  • "G7 acquisition doorways settled — lab toggles hardcoded true." These were lab toggles that
    are now hardcoded on. Whether that is the right shape for upstream, or whether they want to stay
    configurable, is a design call for this review.

Conflict resolved (please check this one)

G7BluetoothManager.init conflicted. next-dev (#63) had extracted central-manager creation into
a makeCentralManager(queue:) factory seam so tests can substitute a manager without the state
restoration option; Jeremy's commit had inlined #if os(iOS) guards at the same call site.

I kept your factory and moved the platform distinction inside it:

func makeCentralManager(queue: DispatchQueue) -> CBCentralManager {
#if os(iOS)
    return CBCentralManager(delegate: self, queue: queue, options: [CBCentralManagerOptionRestoreIdentifierKey: "com.loudnate.CGMBLEKit"])
#else
    // watchOS has no CoreBluetooth state restoration; the watch host owns reconnect policy.
    return CBCentralManager(delegate: self, queue: queue, options: nil)
#endif
}

This preserves both intents and keeps G7CGMManagerTests.TestBluetoothManager's override of
makeCentralManager working. It is the one place in this PR where the resulting code matches
neither side verbatim, so it is the thing to check hardest.

Everything from #63 — the suspected-session-end grace period, its persistence across termination,
and its tests — is carried forward untouched.

Verification

  • xcodebuild -workspace LoopWorkspace.xcworkspace -scheme LoopWorkspace against stock
    next-dev
    (stock LoopKit, this branch + the RileyLinkKit branch swapped in): BUILD
    SUCCEEDED
    .
  • -scheme G7SensorKit-watchOS -destination 'generic/platform=watchOS Simulator': BUILD
    SUCCEEDED
    .
  • Content diff against Jeremy's G7SensorKit tip is exactly the intervening next-dev commit plus
    the conflict resolution above.

The G7SensorKit test suite has not been run yet.

Jeremy Barnum and others added 11 commits September 7, 2026 12:51
Mirrors the LoopKit-watchOS pbxproj pattern (SDKROOT=watchos, SUPPORTED_PLATFORMS
watchos/watchsimulator, TARGETED_DEVICE_FAMILY=4, APPLICATION_EXTENSION_API_ONLY,
PRODUCT_NAME=G7SensorKit, WATCHOS_DEPLOYMENT_TARGET=8.0). Compiles the full core
(23 sources: parsing/state/messages/Common + the CoreBluetooth passive-listener
stack, which builds for watchOS with the two os(iOS) restoration guards).

Excluded from the watch target: G7DeviceStatus.swift (imports LoopKitUI, iOS-only;
consumed only by G7SensorKitUI). Links LoopKit.framework (resolved to the
LoopKit-watchOS product by name, as OmnipodKit-watchOS does). Shared scheme added
for the standalone compile gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- G7BluetoothManager.swift: #if os(iOS) around the two CoreBluetooth
  state-restoration sites (restore-identifier init option, willRestoreState).
  watchOS branch passes options: nil; zero behavior change on iOS. Same recipe
  as the M2 OmnipodKit/RileyLinkBLEKit guards.
- G7Sensor.swift: activationDate internal -> public (one line) — the minimal
  transport-injection seam so an external transport (the watch's direct-BLE
  G7Client) can drive the stock G7SensorDelegate flow into G7CGMManager.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ependency hijack fix

The stock iOS G7SensorKit framework target used SDKROOT = auto, which XCBuild
treats as platform-specializable: the WatchApp Extension's name-based implicit
dependency on G7SensorKit.framework then resolved to the iOS target instead of
G7SensorKit-watchOS, dragging the iOS dependency closure's SwiftCharts package
modulemap flags into the watchos build description (66x 'module map file ...
GeneratedModuleMaps-watchos/SwiftCharts.modulemap not found').

Pin SDKROOT = iphoneos on the target's two configs — the shape LoopKit's iOS
target already has, and zero effective change on iOS (SUPPORTED_PLATFORMS
already pins iphoneos there). Full diagnosis: Loop/docs/M3_NOTES.md §7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 2026-08-10 held-pod-link finding (0 adoptions in 3 held-link windows, 2 adoptions within
minutes of the 2 releases) was proven by toggle experiment but the mechanism stayed inferred:
this layer logged only via os_log, invisible to the on-watch mirrored log the field analysis
reads.

G7RadioCensus.sink (public; watch wires it, phone stays os_log-only) now reports:
- scan-start branch taken: retrieved-known / system-connected list (the literal D2W piggyback,
  trigger a — a timing lottery against D2W's ~10-20s windows) / scan armed
- connection events (trigger b — the OS reporting D2W's own connects), ALWAYS logged
- ad discoveries (trigger c), rate-limited to one line per peripheral per 30s — presence vs
  absence per window is the scan-starvation discriminator
- didConnect with peripheral identity
- at both unknownCharacteristic throw sites: the full discovered GATT inventory, which
  distinguishes partial-discovery-on-a-dropped-shared-link from wrong-GATT — the ambiguity
  that made three days of auth errors uninterpretable

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit ec73922)
- G7RadioCensus publishes connectPendingSince / lastRideSignalAt (lock-protected)
  so the app can gate pod radio work on live G7 acquisition state
- census lines at didFailToConnect + didDisconnect (the invisible failing half
  of the 23:31:52-59 churn cycle)
- duplicate-connect guard in handleDiscoveredPeripheral (re-registered
  connection events re-fired CONNECT ~10/s during a failed ride)
- scanAfterDelay single-flight (N failures scheduled N rescans)

(cherry picked from commit 3a6e3f3)
…nnected

The known-sensor branch retrieved the peripheral and issued a bare pending connect()
with NO scan armed (the arm condition was activePeripheral == nil, and the branch makes
it non-nil). A pending connect rides bluetoothd's own duty-cycled background scan, which
against the sensor's 1-4s advertising burst per 300s window is a lottery: measured
2026-08-19/20, seven consecutive windows missed, 20-40 min outages ending only when the
sensor escalated to its ~60s distress cadence (also measured -- the 'exact 300s grid' is
the collected regime only). Both collectors (ours and D2W) healed simultaneously because
whoever finally connects, the other rides via trigger (b).

The condition is now activePeripheral?.state != .connected: a scan is armed for the
whole pending phase and catches the FIRST burst. didConnect stops it via readied ->
managerQueue_stopScanning, and handleDiscoveredPeripheral's #101 guard makes a
discovery-during-pending a no-op, so it cannot churn. Crude proved the same lesson:
scan is the primitive.

Plus the H14 scan watchdog: one full sensor window (320s) with acquisition armed and
NOTHING delivered is deafness, not bad luck -- recycle the scan and any stuck pending
connect, and say so in the census. Correct under every theory of the deafness.

Plus the Radio Lab gates: G7Lab.trigger.a/b/c read at use, shipped defaults unchanged.
Ported from the pure/SportMode line. The census already logged every peripheral
name it saw, but only inside sentences, so "which sensors are actually in range"
could only be recovered by parsing log strings.

The host needs it as data to break the stranded-identity trap: when the persisted
sensor is gone and a replacement is advertising beside it, nothing in the manager
can notice — it learns a new sensor's ID only by talking to it, and it is busy
failing authentication against a corpse. Fired from the two sites that hold a
peripheral name: ad discovery and connection events.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
From the pure line, where a grep for Code=11 returned zero while 34
connection-limit failures sat in the log as localizedDescription text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All three (ride + events + scan) proven over weeks of field use; the
UserDefaults reads are gone so a stale false from an old experiment can
never silently disable a doorway. Accessors stay so call sites read as
gates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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