2026-10-16 · investigation report · Pixel 10 Pro (Android 17), Garmin Instinct 3, Garmin Connect 5.30.1, Libre 3 Plus
Feed the Garmin Connect IQ glucose apps the user already runs with our own phone app and no other CGM app (no Juggluco, xDrip+ or GlucoDataHandler). The three targets:
9c13aba4-…)9521d9b2-…)43c8e77b-…)Building our own watchface, app and datafield is the next spike.
All three work with GlucoBridge alone. GlucoBridge is the direct flavor from 02: it
reads the sensor itself and now also serves an xDrip+-compatible web service on 127.0.0.1:17580. Juggluco was
force-stopped for the whole test (00:44–01:11). No Connect IQ settings were changed: the watch apps stay in their
“Juggluco” mode, and our server answers the way Juggluco’s does.
| Target | Seen on the watch |
|---|---|
| CGM+ BG app (glance + app) | Current value (“74 →, now”) and graph. The 1 h graph showed the gap from the test restarts, then filled in |
| Instinct3 N - PRO face | Value via CGM+ GB’s complication (the face itself makes no network requests) |
| CGM+ BG Datafield | Current value during an activity |
| CGM Connect (bruder_jakob), also installed | Current value (“73 →”), and sensor age via sensor_status |
| CGM Gauge Widget (Roboleo), unused glance | Made no request at all; its data was ~279 days old, so it wasn’t working with Juggluco either. Out of scope |
Libre 3 ──BLE──▶ GlucoBridge (phone) ── 127.0.0.1:17580 HTTP ◀── Garmin Connect app ◀──BLE── watch app
serves /sgv.json (makeWebRequest proxy: the watch asks, the phone app fetches)
A Connect IQ app on the watch can’t open sockets. It calls Communications.makeWebRequest, and the Garmin
Connect app on the phone performs the HTTP request and returns the JSON over Bluetooth. Garmin Connect only allows
plain HTTP to 127.0.0.1/localhost; anything else must be HTTPS. That is why the whole CGM-on-Garmin ecosystem
converged on xDrip+’s “local web service” on port 17580. Juggluco, GlucoDataHandler and AAPS (port 28891) all
copy it. None of the three targets uses Connect IQ phone-app messaging: the watch always pulls.
So “no other app” means: our app must own port 17580 and speak that dialect. Garmin Connect itself stays; it is the only bridge to the watch.
Every request was logged by the server (evidence). Three fixed patterns
repeat every 5 min, a few seconds apart, all with user-agent: Mozilla/5.0 ( compatible ) and
x-app-ver: 11657 (Garmin Connect’s build, not the watch app’s):
| Request | Content-Type sent | Likely app | Asks for |
|---|---|---|---|
GET /sgv.json?count=72&brief_mode=true&interval=300 |
form-urlencoded | CGM+ GB (feeds the face and datafield through its complications) | 6 h at 5 min |
GET /sgv.json?all_data=Y&count=72.0&sensor=Y&interval=600&brief_mode=true |
application/json | CGM Connect | 12 h at 10 min + sensor age |
GET /sgv.json?all_data=Y&count=12&sensor=Y&interval=300&brief_mode=true |
application/json | CGM Connect | 1 h at 5 min + sensor age |
The app attribution comes from the documented parameters and the polling cadence. Garmin Connect doesn’t log which app made a request.
interval is Juggluco’s parameter, not xDrip’s. It sets the minimum spacing between returned readings. The
apps are in “Juggluco” mode, so they send it.count=72.0: Monkey C sends floats. Juggluco’s parser stops at the .. A strict integer parser silently
fell back to the default 24 (our first version did, and returned 2 h instead of 12 h).brief_mode.XdripServer.kt, ~250
lines, no dependencies. It copies Juggluco’s output field for field (captured samples):
[{"date":1792102244000,"sgv":75,"delta":-0.250,"direction":"Flat","noise":1,"units_hint":"mgdl","sensor_status":"03-10-26 18:03 (12d 7h)"},
{"date":1792101649000,"sgv":72,"delta":-1.300,"direction":"Flat","noise":1}, …]
sgv is always mg/dL; the watch converts. delta is mg/dL per 5 min. For live readings it is the sensor’s
rate × 5, which is what Juggluco reports; for 5-minute history points it is the slope to the previous point.
direction uses the Nightscout names (±1/2/3 mg/dL/min).interval − 30 s apart (default 270 s, Juggluco’s). Without the slack, Libre’s ±1 s
jitter turns a 300 s request into 6-minute steps.brief_mode, the full xDrip entry is sent (_id, device, dateString, sysTime, filtered,
unfiltered, rssi, type). units_hint goes on the first entry, plus sensor_status when sensor=Y./api/v1/entries/sgv.json, /api/v1/entries.json, /pebble, /status.json, HEAD. Unknown paths → 404.
api-secret is not checked: loopback only.files/readings.csv. The sensor’s stored history has a 5-minute step and lags
~16 min, so without persistence every service restart left a 10–15 min hole in the watch graphs (seen during the
test: the user saw it in CGM+’s 1 h graph).accept(). There is no polling and no wakelock. The work is ~3 requests per
5 min, each a few ms. Not measured separately (the phone was needed for investigation 11’s battery runs).direct flavor. In the juggluco flavor, Juggluco’s own server must keep the port; whoever
binds first wins, and that could silently starve the watch.127.0.0.1:17580 through Garmin Connect. It’s simple and robust, but
the watch polls every 5 min, so the watch lags the 1-minute sensor by up to 5 min (plus the apps’ own offsets).
Push needs our own watch app (next spike).interval). An xDrip-shaped server would ignore them and still mostly work, but would return the
wrong spacing or count."direction":"" (seen right after it reconnected). Ours always carry a
direction or "NONE"./api/v1/entries.json?interval=0 and its SSE stream /x/live. Our server returns 404 for
/x/live, so GlucoStatus fell back to polling every 5 s and CGM Nostr reported “Juggluco unreachable”. The real
app should serve all of its own consumers, or implement /x/live.ciq-companion-app-sdk) lets the phone app send messages to
our own watch app through Garmin Connect. AAPS (to its Glucose* apps) and Juggluco (to Kerfstok) do this. Juggluco
even ships an experimental direct BLE path to the watch that bypasses Garmin Connect
(DirectGarminIQ.java, Garmin’s 6a4e8022-… service), which is worth studying.CGM-GB-value / CGM-GB-data /
CGM-GB-graph, documented at watchface.io/docs/cgm/developer), and
short label CGM. Then the user’s Instinct3 N face (fields “CGM+ GB” / “CGM (Other)”) can show our data even before
we have our own face.-m owner --uid-owner <Garmin Connect> redirect of
127.0.0.1:17580 to our own port, or a hook on Garmin Connect’s HTTP proxy. The watch then reads us while
Juggluco/GDH keep their server, so the port-17580 conflict in finding 4 goes away.makeWebRequest inside Garmin Connect. Hook the proxy and return our JSON directly, with no socket
and no HTTP-only/HTTPS rules. Also check whether the “HTTP blocked after a while” bug can be patched there.DirectGarminIQ.java). Removes the dependency on the public SDK’s behaviour.https://apps.garmin.com/api/appsLibraryExternalServices/api/asw/apps/<uuid>./x/live (Juggluco SSE) and /api/v1/treatments.json, if any consumer needs them.?key= / api-secret. Unused here.cd spikes/glucobridge-android
export JAVA_HOME=$(brew --prefix openjdk@17)/libexec/openjdk.jdk/Contents/Home PATH="$HOME/.local/bin:$PATH"
./gradlew testDirectDebugUnitTest assembleDirectDebug # XdripServerTest replays the watch's queries
adb install -r -g app/build/outputs/apk/direct/debug/app-direct-debug.apk
adb shell am force-stop tk.glucodata # frees the sensor *and* port 17580
adb shell am start -n dev.spike.glucobridge/.MainActivity
adb logcat -s GlucoBridge.Xdrip # one line per watch request
adb shell run-as dev.spike.glucobridge cat files/xdrip-requests.log
Watch side: CGM+ BG app → “CGM System” = Juggluco (or xDrip+), “Allow Access from Other Apps” on; face field =
“CGM+ GB”. To go back: relaunch Juggluco, then reinstall the juggluco flavor if you used it before.
Sources: watchface.io CGM docs (sources,
settings, developer),
CGM Connect docs, xDrip local web service,
Juggluco watchserver.cpp, AAPS GarminPlugin,
Garmin Communications API.