Run the MiniMed pipeline on the Pebble 2 Duo directly, no phone. Spinoff of the working
phone-bridge project (../minimed-pebble-bridge). This file is the stable map: current state,
how to build and test, the code map, and what is left. Topic detail lives in its own file.
| File | What is in it |
|---|---|
BATTERY.md |
Drain measurements, how to run a battery test, ranked hypotheses, remaining experiments |
CONNECTIVITY.md |
Bonds and pruning, advertising, the single-slot contest, dual connection |
WATCHFACE.md |
The launch-crash saga (dormant) and the launch-gap gotcha |
TESTING.md |
The build/flash/test loop, pump and watchface test procedures |
VERSIONS.md |
Every build vN, newest first, plus the chronological dev log |
../Documentation/ |
Pump protocol source of truth — update eagerly |
- Personal DIY-diabetes project: Morten (type 1) reads his own glucose from his own Medtronic 780G, offline, onto his own watch. No cloud, no third party.
- Read-only by design, and always will be. Only read/report requests (glucose, IOB, status, history). No path to any command/settings characteristic; the only control-point write is the standard RACP "report stored records" query. Cannot dose, calibrate, or change settings.
- Built on public open-source RE (OpenMinimed, GPL-3.0) with published model-static keys — same as the working phone bridge. Same category as OpenAPS/Loop/xDrip (decade of precedent).
- Pump + CGM remain the authoritative devices; this is a supplementary display.
Battery is the current fight, and the first decisive number is in. A night on stock 4.31.1 (2026-08-02/03) drained 0.165 %/h against 0.57–0.72 %/h for our firmware in nearby SoC bands — roughly 3.5–4×. Combined with Morten's experience of good battery on older stock builds, that puts the cost in our diff, not in the 4.24 base we forked from and not in the hardware. v44 adds hourly
hblog lines (drain at 0.01% resolution plus CPU-sleep, per-task CPU, advertising and connection-interval seconds, flash writes) so the next spike night can say what costs it. Everything battery:BATTERY.md.
- End-to-end PROVEN on real HW (2026-07-21): advertise as "Mobile PB" → pump connects → SAKE handshake → GATT-client CGM read → decrypt → continuous auto-updating BG in mmol/L, matching the pump's display exactly. Feasibility fully settled; the rest is productization.
- 2026-09-26, HW-VERIFIED (build
acb488d07): the watchface was always one CGM cycle behind. A push scheduled from the trend setter ran beforesend_bghad updated the BG string, and the correct push was then swallowed by the coalesce window. Setters are now state-only andminimed_sake_sender_commit()emits exactly one frame per completed read op (prv_op_complete). Details: VERSIONS.md entry; the model is in the code map below. - 2026-09-26, HW-VERIFIED (build
3d27714fc): the two-hour graph backfill works. It had never run since it landed: the history dispatch returned early for non-annunciation records, so the backfill and meal parsers only ever saw malformed ones. On connect the watch now logsbackfill 25 of 32 samples ... anchor=matchand the watchface'sgraph=count fills to the window. Details: VERSIONS.md entry. - v66 BUILT 2026-09-06, awaiting HW: watch fetches BG as soon as the sensor recovers. It used to wait for the pump's "new CGM" push bit or the 6-minute fallback, so a pump that resumed on an existing record left the watch blank for minutes. Details: VERSIONS.md v66 entry.
- v65 BUILT 2026-09-05, awaiting HW: pump connectivity events now reach the flash log. They
were ring-only, which is why the v63/v64 bug took two nights —
pump adv job FAILwas being logged all along and was invisible todump_flash_logs.py. The per-poll read path and the ~1 Hz advert-rotation lines are deliberately left on the ring. Details: VERSIONS.md v65 entry. - v64 BUILT 2026-09-05, awaiting HW: the real fix for the pump not returning after a BT restart.
The DUAL re-arm ran inside
bt_driver_start, butgap_le_init()runs right after and resets the advert scheduler — the pump's advert job was freed before it could ever air. Moved tominimed_sake_bt_started(), called frombluetooth_ctl.caftergap_le_init(). v63 was the wrong diagnosis. Details: VERSIONS.md v64 entry. - v63 (2026-09-04) did NOT fix the post-restart pump reconnect — superseded by v64. The
advert scheduler stops advertising on connect and never re-airs, so under DUAL the pump's job
went off air whenever the phone connected after it was scheduled — twice overnight, costing 86
and 160 minutes of pump link. Re-air from
gap_le_advert_handle_connect_as_slavewhen advertise-while-connected is set. Details: VERSIONS.md v63 entry. - DUAL LINK HW-VERIFIED 2026-09-03 (v62): phone and pump connected at once on asterix. BG and
IOB flow from the pump while the phone session stays up, so
pebble logs/ sideload no longer cost the pump link — the tooling unblock that motivated this whole item. Capture:../logs/watch/2026-09-03-g0-v62-dual-link-first.txt. v58's DIS sweep fix verified in the same session. Details: VERSIONS.md v62 entry. - v62 fixes the v61 boot hang (PRF). v61 read the bonding DB
from inside
for_each_ble_pairing's callback, which holds a non-recursive mutex -- self-deadlock on the BT host task during init, croak, reboot loop, PRF. Filter after the iteration (assettings/bluetooth.c:157already documents) and move the lookup to KernelMain. Details: VERSIONS.md v62 entry. - v61 BOOT-LOOPED TO PRF, do not flash (superseded by v62): pump identity actually persists. v59's
pumpaddrkey was never written on an already-paired watch, so the pump's first connect of every boot was classified as the phone and its disconnect then swallowed, leaking a GAPLEConnection. Persist on handshake, and recover from the bond store for watches paired before the key existed. Details: VERSIONS.md v61 entry. - v60 BUILT 2026-09-03, awaiting HW: v59's dual link ported to asterix. v59 raised
BLE_MAX_CONNECTIONSonly on sf32lb52, so DUAL had no second connection slot on this watch; the nrf52 syscfg now matches (spike-gated, +640 B KERNEL_RAM). Also fixes a stock-build compile break inadvert.cand restores the asterix recipe tominimed-build.shas a board profile (default asterix,--pt2for obelix). Details + what to suspect first: VERSIONS.md v60 entry. - v59 BUILT 2026-08-30, awaiting HW: dual link (phone + pump at once) PoC. Toggle is now
NORMAL⇄DUAL; NORMAL is a full
bt_ctl_reset_bluetooth()restart (kill switch for sideload).BLE_MAX_CONNECTIONS2, pump's own advert job, swallowed pump link, pump-identity persistence, pump-pairing window. Details + HW checklist: VERSIONS.md v59 entry. - Reconnect HW-VERIFIED 2026-07-22 (v16): after a NORMAL⇄SPIKE toggle the pump reconnected by itself, re-ran the handshake, BG resumed.
- Rebased onto upstream v4.36.0 (v54, HW-VERIFIED 2026-08-24). The 4.24 base capped the
installable-app SDK version at
0x66, so watchfaces built with SDK 4.33.1 (stamped0x6a) were rejected with "This app requires a newer version of the Pebble firmware"; v4.33.1+ firmware raises the constant. Pump pipeline and the existing bond came through the flash intact; pairing from scratch is untested on this base. Rebase recipe (three conflicts,rerere, the silentlogging.hmove) in the VERSIONS.md v54 entry. The base also carried one upstream regression: an hourly assert-reboot in the analytics heartbeat, fixed in v55 (HW-verified overnight 2026-08-25, 7h37m uptime). It presented as "SPIKE reverts to NORMAL after a while" becauses_modeis RAM-only — worth remembering as a diagnosis pattern: an unexplained mode revert means look for a reboot first. - Pump firmware revision is
8.12.2(v56, HW-VERIFIED 2026-08-29). The baseline for comparing against a newer, Simplera-Sync-capable pump; OpenMinimed'stodo.mdlists the characteristic as never captured, so it is also a doc contribution once compared against the pump's own menus. - v58 BUILT 2026-08-29, awaiting flash (
build/sake-spike-v58-devinfo-safe.pbz): reads all nine Device Information characteristics once per boot. Replaces v57, which hard-faulted twice and dropped the watch to PRF — losing the pump bond. Root cause unproven (the coredump is unreachable); v58 removes the suspected cause, a stack-hungry GATT callback. Evidence and the ruled-out theories are in the VERSIONS.md v58 entry. A logging change in a BLE callback can cost a re-pair — treat that path as risky. - v49 BUILT 2026-08-17, awaiting flash (
build/sake-spike-v49-iob-log.pbz): logs raw IOB milliunits to the flash log on every SRCP read (previously UI-ring-log only, so dumps had no IOB values) — data for recovering the pump's insulin decay curve. Carries v48 unchanged; checklist in the VERSIONS.md v49 entry. - Pump alarms on the watch: HW-VERIFIED (v48, 2026-08-19), UI settled (v52, BUILT 2026-08-22, awaiting flash). Annunciations read via IDD history on the 0x101 annunciation bit; shown as native notifications, title = pump wording, body = latest BG. The v51 Quick View banner worked but was dropped — revive instructions in the VERSIONS.md v52 entry.
- v45 BUILT 2026-08-16, awaiting flash (
build/sake-spike-v45-sg-markers.pbz): 0 mg/dL CGM records are markers, not readings — off-scale shows LO/HI (band dropped), other sensor states let the BG go stale instead of showing 0.0 / graphing a 0-cliff. HW checklist in the VERSIONS.md v45 entry (a CHANGE SENSOR state is live right now — good first test). - ON THE WATCH NOW: v41 pump status — HW-VERIFIED 2026-07-28, 23 h soak
(
build/sake-spike-v41-pump-status.pbz). Full bridge mirror on the watchface: BG + IOB + status band (suspend/temp-target/warm-up countdowns, BG "---" blanking) — details in the version log. Carries v40 pump push (HW-verified, 10 h soak). Everything v36 verified still holds: pump pairs, neither bond is ever lost — reboot and phone re-pair both verified. The watchface launch crash did NOT reproduce on v36+ (see OPEN→DORMANT below).
- Pebble 2 Duo = board
asterix(nRF52840, B&W 144x168). BLE = NimBLE (src/bluetooth-fw/nimble/), host + controller on the same chip. - Pebble Time 2 =
obelix, Round 2 =getafix(SiFli): same NimBLE host, different controller — re-verify before trusting.
→ See TESTING.md for the full loop (./minimed-build.sh <desc> builds+versions+adb-pushes;
BT sideload steps; pump + watchface test procedures; the FE81/FE82 reconciliation fix; log
vocabulary). Quick facts kept here:
- No local toolchain; build in Docker image
ghcr.io/coredevices/pebbleos-docker:v6(official CI image, not the oldpebbleos-build:local).minimed-build.shwraps the whole Docker invocation. - Everything is behind Kconfig
CONFIG_MINIMED_SAKE; boots NORMAL (ordinary Pebble). MiniMed app: SELECT = NORMAL⇄DUAL, DOWN = forget pump, Back = exit. - Crypto changes: run
tools/minimed_sake_hosttest/(make run, 24/24) before reflashing.
The spike now builds and boots on the Pebble Time 2 (obelix@pvt, SiFli SF32LB52). The working
recipe is exacting; see TESTING.md for the full failure table. Essentials:
- Build release (
CONFIG_RELEASE=y) and produce ONE correctly-linked single-slot bundle per slot — no dual-slot repack, no manifest rewrite.minimed-build.shemits_slot0.pbzand_slot1.pbzand shares both. - Keep a release-form annotated git tag (default
v4.36.9) on HEAD so the manifest versionTag parses in the Pebble app and encodes as release band. - The app targets the slot NOT running (
1 - runningSlot) and requiresfirmware.slotto match, so flash whichever slot bundle the app asks for — the "does not parse" that appeared after a working slot0 flash was this slot race, not a build regression. - Verify band 0x01 and version > stock (4.36.2) before flashing.
This is the debugging unblock, and it did not need dual connection. Every PBL_LOG line is
written to flash continuously (flash_logging_set_enabled(true) at init) and kept per boot
generation, so a SPIKE session with no phone attached is still fully recorded. Reproduce a
problem in SPIKE, toggle to NORMAL, and pull the history:
adb forward tcp:9000 tcp:9000
tools/dump_flash_logs.py # this boot
tools/dump_flash_logs.py -g 1 # previous boot
tools/dump_flash_logs.py -g 2 --dict build/sake-spike-v35-….loghash.json
Verified on hardware: recovered the complete SAKE handshake (pump WRITE conn=1 len=20 …) from a
SPIKE session, dehashed and readable, over the phone afterwards. That is most of what "live logs"
was wanted for — so Stage 2 dual connection is now a convenience, not a debugging prerequisite.
Details worth knowing:
- It needs
libpebble2, which lives only in the pebble-tool venv, so the script re-executes itself with that interpreter when the import fails — anypython3works.pyelftoolsis needed there too, for the dehasher. It is auv-managed tool venv, so auv toolupgrade/reinstall rebuilds it and drops anything pip-installed by hand — the symptom isCould not import logdehash (No module named 'elftools')and every line coming back asNL:xxxx. Reinstall it as a recorded extra so it survives:uv tool install pebble-tool --with pyelftools. - The dict is per build. Log lines are stored hashed and the hashes change every build, so an
older generation needs that firmware's dictionary.
minimed-build.shnow archives one next to each.pbzasminimed-<board>-<describe>-<desc>.loghash.json(<describe>=git describe --dirty --alwaysfor that build); pass it with--dict. Without the right dict the lines come back as rawNL:xxxx. - Requires Developer Connection enabled in the Pebble app and the watch in NORMAL. Re-pairing
the watch turns Developer Connection off — that cost time once; the symptom is
"Connection to remote host was lost" while port 9000 still accepts, i.e. the server is up but has
no watch.
pebble screenshotworking does not mean Developer Connection is on. - Useful forensic line at every boot:
debug.c:211 Last launched app: <…>.
The firmware has a real clar unit-test suite (327 tests) that runs on the host in ~1 s once built.
tests/fw/services/bluetooth/test_bluetooth_persistent_storage.c in particular covers the bonding
DB, so bond-storage work can be genuine TDD with no hardware. Run it in Docker like everything else:
docker run --rm -u "$(id -u):$(id -g)" -e HOME=/tmp -v "$PWD":/pebbleos -w /pebbleos \
pebbleos-build:local bash -lc "
git config --global --add safe.directory /pebbleos
./waf test -M '.*bluetooth_persistent_storage.*'"
- Never run
./waf configurewithout--board— it wipesbuild/c4cheand de-configures the firmware build. Restore with./waf configure --board asterix -DCONFIG_MINIMED_SAKE=y(in Docker, with/opt/pebbleos-sdk/arm-none-eabi/binon PATH); verify withgrep MINIMED build/autoconf.h. -Mis an anchoredre.matchagainst the test source path.-M bluetooth_persistent_storagematches nothing and the run still says "finished successfully" having built no tests. Always wrap it:-M '.*name.*'. Drop-Mentirely for the full suite (~40 s)../waf testuses thetestwaf variant (build/test/), so it does not clobber the firmware build and is fine to run with the asterix configure in place. First run ~2 min.
ghcr.io/coredevices/pebbleos-docker:v6 is the official CI image; it installs deps itself
(pip install -r requirements.txt) inside each container run, so no docker commit is needed.
Submodules must be checked out (resources/iconography is required or the resource build dies).
New files (all spike-only via wscript/ifdef):
minimed_sake_aes.{c,h}+minimed_sake_crypto.{c,h}— SAKE server handshake + session cipher in C (AES-128/CMAC/CTR, SeqCrypt, key-DB parse, 6-msg state machine). Self-contained; byte-verified vs the captured 780G trace bytools/minimed_sake_hosttest/(same sources, 24/24).minimed_sake_service.c— SAKE Port GATT server (svc SIG0xFE82, char vendor0000fe82-…-009132591325) + required vendor-0x0900DIS (9 placeholder chars) + advert payload builder (FE82/FE81 bys_pump_paired) + handshake driver (embeddedKEYDB_PUMP_EXTRACTED, RNG =ble_hs_hci_rand, re-init per subscription, deferred notify 120ms wake-up / 30ms replies) +minimed_sake_decrypt+pump_paired/forget_pump.minimed_iob.{c,h}— pure IOB decode/parse (NimBLE-free so the host harness links it):minimed_iob_decode_medfloat32_mu(medfloat32 → milliunits, faithful/overflow-guarded, no business gate) +minimed_iob_parse_response(SRCP0x03FCopcode check + medfloat32 at offset 3 + 0..100 IU plausibility gate). Host-tested (§Section 4, 8 checks) vs OpenMinimed vectors incl. the live-confirmed 1.4 IU frame.minimed_sake_read.c— post-handshake reads, watch as GATT client:- CGM (BG): discover
0x181F, chars2AA7/2AA8/2A52; read Feature (plaintext); subscribe Measurement (notify) + RACP (indicate), CCCD = val_handle+1; poll RACP01 06every 60s. Measurement is SAKE-encrypted → decrypt → reassemble by size byte → SFLOAT → mmol/L. - IOB (v29): after CGM setup, discover IDD service
0x100+ SRCP char0x105(128-bit vendor UUIDs, same base as the SAKE port char); subscribe SRCP indicate (CCCD = val+1). On each CGM RACP-success, chain (via ~200 mss_iob_cocallout, after the CGM gattc op finishes) a SAKE-encrypted SRCP0xF3 0x03write; decrypt the0x03FCindication →minimed_iob_ parse_response→send_iob. NO byte-0 length prefix (unlike CGM). All IDD-discovery failures fall back to BG-only polling (s_h_srcp==0). Shared inboundclient_cryptstays in sync because CGM→IOB is serialized per poll.
- CGM (BG): discover
minimed_annunciation.{c,h}— pure decode of IDD History Data records for pump annunciations (alarms/alerts): record header + Annunciation Consolidated (0xf010) fields (type/id/status/silenced) + the type→name table (from PythonPumpConnector AnnunciationType). Host-tested. The BLE side lives inminimed_sake_read.c(v48): IDD RACP 0x2A52 + History Data 0x108 subscription, per-connection "last record" baseline, 0x101-bit-3-triggered catch-up reads, dedup by instance id; notifications viasrc/fw/popups/minimed_alert_popup.{c,h}(BT task → KernelMain →notifications_add_notification, native popup/vibe, raise-only, silenced raises skipped).minimed_sake_sender.c— the watchface local-sender: loopback CommSession (QEMU-transport pattern) opened in SPIKE mode only (would compete with the real phone session in NORMAL); injects[PP hdr 0x0030][CMD_PUSH][target UUID][dict: announced keys only]viacomm_session_receive_router_writeon KernelMain underbt_lock. Setters only store state and sets_dirty;minimed_sake_sender_commit()builds one frame per completed read op (prv_op_complete) and per pump-link transition (advert.c), so a frame is never assembled from half-updated state.send_bgandsend_iobare separate setters (BG/IOB arrive from different reads);send_iobdeliberately does NOT advance the BG timestamp.send_nextdrains the watchface's outbox: an announcement → ACK + immediate push, anything else → NACK + blacklist; its ACKs of our pushes are swallowed (loop guard). The target UUID and the key set are learned from the announcement, not hardcoded (item 10 under Remaining work). Protocol:pebble-glucose-protocol/PROTOCOL.md.minimed_glucose_announce.{c,h}— pure parser for the capability announcement, which doubles as the "is this watchface one of ours?" test. Host-tested (section 9).pebble_glucose_protocol.h— verbatim copy of the spec's header (keys + capability bits).src/fw/popups/minimed_sake_ui.{c,h}— MiniMed core: log ring buffer, mode flag, stage reports; declares the app↔BT-layer seam (force_readvertise,pump_paired,forget_pump,sender_set_mode).src/fw/apps/system/minimed_sake_app.{c,h}— launcher app (viewer + buttons).
Modified:
Kconfig(+MINIMED_SAKE),wscript_build(MiniMed sources, incl.minimed_iob.c),init.c(register service),system_app_registry_list.json(app id-200).minimed_sake_service.{c,h}— addedminimed_sake_encrypt(server-direction wrapper, mirror ofminimed_sake_decrypt; used for the SRCP request) andminimed_sake_addr_is_pump(RAM-only pump identity captured at handshake DONE, for the v29 conn label).advert.c— SPIKE-mode advert hijack (Medtronic payload; mfr data0x01F9carries the name); interval clamp 100–140ms; own-addr type by mode;force_readvertise()=gap_le_advert_force_ data_refresh()+ drop the active link (v27 rearchitecture); NOTIFY_RX routes pump notifications to the read layer first; disconnect stops polling; repeat-pairing auto-recovery re-enabled. v29 diagnostics:advS/advNbyte dumps of the actual advert payload;conn PUMP/phone m=S/N.gap_le_advert.{c,h}— addedgap_le_advert_force_data_refresh()(v27; nullss_current_ad_data- re-airs, modeled on
bt_driver_handle_host_resynced), so a mode toggle re-pushes the payload.
- re-airs, modeled on
third_party/nimble/port/include/nrf52/syscfg/syscfg.h— SM config for the pump's legacy Just Works (SC_ONLY 0, LEGACY 1, IO NO_INPUT_OUTPUT, MITM 0) +BLE_SM_OUR_KEY_DIST 1→3(distribute IRK + identity so the pump can resolve our RPAs).
- SAKE is purely symmetric AES (ECB key-derivation, CTR payload, CMAC tags). No ECDH.
- UUID bases: SIG
-0000-1000-8000-00805f9b34fb; Medtronic vendor-0000-1000-0000-009132591325(variant0000, not8000). SAKE service = SIG; SAKE char = vendor (mixups → pump finds the service but not the char). - Advertising: pump scans svc class
0xFE82first-pair /0xFE81reconnect; name in mfr data (company0x01F9), not GAP name; ignores adverts slower than ~150ms; only reconnects to an RPA, and needs our IRK + identity distributed at pairing to resolve it (HW-confirmed 2026-07-22; noted inDocumentation/bluetooth.md). Handshake re-runs fresh on every reconnect — no resume. - Pairing: legacy Just Works, NoInputNoOutput (pump requests MITM but has no SC → falls back).
- Pump refuses to pair without the vendor
0x0900DIS (standard0x180Ais not enough). - IOB read HW-confirmed (2026-07-24): IDD service
0x100, SRCP char0x105(write+indicate, CCCD = val+1). Request = SAKE-encrypted0xF3 0x03(opcode 0x03F3), no E2E-CRC; response = SAKE-encrypted single indication, opcode0x03FC+ flags + medfloat32 IOB, no E2E trailer. Confirms the 780G leaves E2E off for the IDD service (already inDocumentation/idd-service.md) and that watch→pump SRCP requests are just SAKE-encrypted with no wrapper. Same machinery should serve the other SRCP gets (pump status 0x102, active basal 0x0365, SmartGuard/TAS 0x03FD). - SAKE init: pump subscribes → watch notifies 20 zero bytes (deferred, never synchronously in
the subscribe callback or the pump misses it) → pump writes 20 zeros → 6-msg handshake. Keys
per device-type pair; DB format in
Documentation/key_databases.md.
Topic-specific ones have moved: the watchface launch-gap crash is in
WATCHFACE.md; the single-slot rule, the FE81/FE82 pairing-state reconciliation
and the phone-bond loop are in CONNECTIVITY.md.
- The host tests are blind to locks —
stubs_mutex.hmakesmutex_lock()a no-op. So a lock-ordering or reentrancy bug passes all 327 tests and then hangs the watch. Specifically: the bonding DB'ss_db_mutexis non-recursive (mutex_create(), notmutex_create_recursive()), andprv_file_eachholds it across the entire iteration — so calling anybt_persistent_storage_*reader from inside afor_each_ble_pairingcallback self-deadlocks. This was caught by review in v36 before flashing (it would have frozen the watch on opening Settings → Bluetooth). Do per-bond lookups in a second pass after the iteration returns;prv_add_ble_remotesinsettings/bluetooth.cis the worked example. - clangd floods spike files with false errors (missing NimBLE include paths). Trust
./waf build. - Don't
docker system prunewithout asking (~100GB of other images on this machine).
-
✅ Reconnect — HW-verified 2026-07-22 (v16);
Documentation/bluetooth.mdupdated. Paired flag persisted in v17. Later maybe: watchdog if advertising ever stalls. -
Real watchface — WORKING (v20, HW-verified): live BG on the actual watchface, screen clean (no "not connected"). No connection-event hack needed. Next display work is additive keys: IOB/status/graph. Design: firmware injects local AppMessages into the existing
../pebble-glucose-watchface, unmodified — same watchface for phone-bridge and on-watch modes. Seam researched (2026-07-22), plan = copy the QEMU transport pattern (src/bluetooth-fw/qemu/qemu_transport.c, the existing phone-less precedent):- Once:
comm_session_open()a synthetic loopback transport (TransportDestinationHybrid,send_nextdiscards — swallows the app's ACKs) underbt_lock. - Per BG: build
[PebbleProtocolHeader len,0x0030 BE][CMD_PUSH,txn][watchface UUID][dict](dict viadict_serialize_tuplets_to_buffer; construction example inlauncher_app_message.c) →comm_session_receive_router_write(session, …)underbt_lock, one kernel task only (router keeps per-session reassembly state). - Delivery hops to the app task via app_inbox_service; the watchface receives a 100% normal
AppMessage. UUID must equal the running app's — only inject when our watchface is
foreground (else clean NACK/drop, no crash). Workers can't receive AppMessages; foreground
only. Bonus: the watchface's outbound messages (e.g. a launch-time sync request) arrive in
our
send_next— parse to trigger an immediate BG push on watchface launch. - Speak the exact key set the bridge sends (see bridge GlucoseFormat / watchface
main.c). Long-term: BLE peripheral APIs in the public SDK (beyond issue #853's client-only ask).
- Once:
-
IOB — DONE, HW-VERIFIED (v30, 2026-07-24). Pump status — BUILT in v41 (2026-07-27), awaiting HW. IOB reads via IDD SRCP
0x03F3→0x03FC, watchface key 14. Status reads IDD Status0x102+ TAS0x03FDand drives watchface key 15 with the bridge's full label set + countdowns — see the v41 version-log entry. Reservoir IU is parsed and flash-logged but not displayed (bridge parity). 3b. ✅ Event-driven push instead of the 60 s poll — DONE, HW-VERIFIED 2026-07-27 (v40, 10 h soak). Stage C (act on more bits: therapy/status/fingerstick) remains open and overlaps item 3. The researched facts below are the reference material behind the v40 design. The mechanism is IDD Status Changed0x101(vendor UUID, IDD service0x100, Read + Indicate), the same service we already discover for IOB. Three things the obvious mental model gets wrong:- It is an indication, not a notification (CCCD
0x02 0x00, pump awaits a confirmation). - The flags are a variable-width self-extending bitfield: little-endian 16-bit blocks, bit 15
of each meaning "another block follows", 16/32/48 bits (
Documentation/idd-service.md:143-149). Field capture: all 296 observed indications were 7 bytes on the wire = 4 plaintext + the 3-byte SeqCrypt trailer. No length prefix, no fragmentation, no E2E on the 780G. - The bits LATCH. You must write Reset Status (SRCP
0x030C+ the exact flags to clear) or the pump indicates once and goes silent forever. So the cycle is receive → read → write back, an extra encrypted SRCP exchange. This is the bridge's gotcha #2 (../minimed-pebble-bridge/ docs/PUMP-DATA.md:33-35), implemented at.../ble/read/IddStatusReader.kt:129-135. Bits the bridge acts on: 0 therapy-control, 3 annunciation, 16 therapy-algorithm, 17 IOB, 18 new-CGM, plus the fingerstick family {20,21,26,27}. The SAKE cipher is NOT a problem — this was the feared blocker and it is settled. The inbound counter is recovered per-frame from a wire byte (minimed_sake_crypto.c:132-147) and committed only after the MAC verifies, so a bad frame is dropped rather than poisoning the session; all pump→watch frames share one ordered ATT bearer and NimBLE dispatches them synchronously on the host task. An async push therefore cannot desynchronise anything. The bridge relies on the same invariant and has run all three producers concurrently for months. What does need serialising is the reassembly buffers:prv_do_pollunconditionally zeroess_rec_len(so two overlapping RACP exchanges truncate a record), ands_srcphas a single IOB-specific "complete at 7 bytes" rule that a Reset Status reply would break. That is a small explicit state machine (one busy flag + a pending-work mask) — the only genuinely new structure. Also correct a stale comment while there:minimed_sake_read.cclaims a second concurrent gattc op returnsBLE_HS_EBUSY. It does not in this build — ops queue FIFO at the ATT layer withBLE_GATT_MAX_PROCS=8. The real hazard is that the 30 s unresponsive timer starts when a proc is queued, not sent, so a burst can time out without ever going on air. Deferring is still right, for a different reason than we wrote down. Related: NimBLE sends an indication's confirmation after the handler returns, so a gattc write issued synchronously from a push handler goes out ahead of the confirmation the pump is waiting for — keep using the callout defer. Stage A — ANSWERED NO (overnight capture 2026-07-26/27). The pump does not notify CGM unsolicited: all 427 measurement notifications arrived within 0–3 s of one of our 428 RACP polls, and zero arrived on their own. There is no free shortcut; event-driven BG requires the0x101machinery. (Analysis trap worth avoiding on a re-run: pair notifications to polls in file order, not by sorting timestamps — same-second events sortnotifybeforepolland every notification then appears to belong to the poll 59 s earlier, which reads convincingly like unsolicited pushes.)0x101latch behaviour CONFIRMED on this pump, and it is stricter than "once". Exactly two indications all night — one per pump connection, each ~1 s after subscribing, both carrying identical flags — and nothing at all during steady state. So without Reset Status you get one indication per subscription, not one per session, carrying the accumulated latched bits. Observed value: rawef 81 4f 00→ bits {0,1,2,3,5,6,7,8, 16,17,18,19,22} (remember bits 15/31 are the block-continuation markers, not real flags). That covers therapy-control (0), annunciation (3), therapy-algorithm (16), IOB (17), new-CGM (18), 19 (rides every CGM push) and sensor connectivity (22) — exactly the "nothing has ever been cleared" set. Stage B: the port proper (~a few hundred lines). Stage C: act on more bits, which overlaps with pump status in item 3.
- It is an indication, not a notification (CCCD
-
Graph — DONE in v34 (awaiting HW); BACKFILL still deferred. The graph is now drawn from readings the watch accumulates itself, so a cold start begins empty and fills over ~2 h. That was the cheap 90%: no new pump protocol, no new failure mode, and it made the staleness fix fall out for free. Pump-side backfill (filling the graph immediately on connect, and closing gaps after a dropout) is the remaining piece and still deserves its own flash. Staged plan: (0, host) port SG_MEASUREMENT 0xF00C + reference-time parse + a multi-record short-PDU reassembler into the host harness, vectors from the bridge's HistoryReader/MedtronicHistoryParser tests; (2a, flash, log-only) IDD-scoped RACP count + report-last-N, log the decoded count/points, no inject — proves the streaming/framing/decrypt on HW; (2b, flash) wire history→graph, enlarge the two sender buffers in
minimed_sake_sender.c(frame[] +96, payload[] +64: a 2 h trace ~78 B, 24 h ~870 B; CommSessionAppMessage8kSupport is already set), add keys 17/18/19, start with a ~2 h window. Multi-record RACP over IDD History0x108, 184-MTU chunked streaming (Documentation/gatt-streaming.md). Watch the exact-multiple short-PDU ambiguity the bridge itself never fully pinned (HistoryReader TODO 48.A2). -
Dual pump+phone — never fairly tested, three known blockers. Full detail, including why the old "radio scheduling starves the link" verdict was retracted, in
CONNECTIVITY.md. Demoted since the flash-log dump gave us after-the-fact SPIKE logs without it. -
Battery — the current job. Stock 4.31.1 drains ~3.5–4× less than our firmware over matched SoC bands, so the cost is in our diff. v43/v44 log the hourly metrics that can attribute it. Numbers, method, confounds and remaining experiments:
BATTERY.md. 6b. Idea: stop servicing the minute-cadence IOB pushes (battery, unmeasured). The 2026-08-17 flash dump (../watch-captures/2026-08-17-g0-v46-sensor-change.txt) shows ~14000x101pushes/day, of which only ~320 carry New CGM; the bulk are IOB/therapy-algorithm ticks (0x28000×487,0x18000×143, …) arriving ~every minute, each costing a decrypt + IOB SRCP exchange + Reset Status write for a value the watchface can't visibly resolve at that rate. The latch is a built-in rate limiter: withhold bits 16/17 from the Reset Status write and the pump stops re-indicating them (pump-side TX included); reset them with the CGM bits so IOB rides the 5-min pushes. Trade-off: IOB lags ≤5 min; check bolus pushes still show promptly (the ×270x280c4pattern suggests boluses touch bits 2/6 too). Do after the battery baseline (item 6) so the effect is measurable. -
Phone-bond papercut (re-pair dance between test cycles) — see
CONNECTIVITY.md. -
Retire the "spike" terminology (queued cleanup, agreed 2026-08-16). The feasibility question was answered around v20; this is the product now, and "SPIKE mode" actively misleads — the toggle means "pump holds the BLE slot" vs "phone holds it", nothing about feasibility. Cheap now: the SPIKE/NORMAL mode labels (→ e.g. PUMP/PHONE) and the "SAKE Spike" app title. Defer the plumbing (
CONFIG_MINIMED_SAKE_SPIKE,spike-build.sh,sake-spike-vNbundle names, "spike" through the docs) to a natural boundary — the upstream-isolation cleanup or the library factoring — since renaming mid-flight breaks grep-continuity with the dev log and the loghash dicts are keyed to old bundle names. Do NOT touchminimed_sake_build_adv's advertised name bytes (load-bearing, see the v34/v35 lesson). -
✅ Full phone+pump bond coexistence — DONE, HW-VERIFIED 2026-07-26 (v36). A flash now costs zero pairings. Detail in
CONNECTIVITY.md. -
Glucose protocol: honour the announcement — DONE, v68 awaiting HW. The sender used to push to one hardcoded watchface UUID and ignore the capability announcement entirely, so installing any other glucose watchface broke the link. It now claims the foreground watchface's UUID (
prv_claimed_uuid, gated onProcessTypeWatchface), confirms it withminimed_glucose_parse_announce, and sends only the announced fields. A watchface that fails the parse is NACKed and blacklisted, so it gets out of the way after a single message. The protocol header is now a copy of the spec's (pebble_glucose_protocol.h) instead of ad-hoc key defines.
The announcement-only regression, found and fixed (v67 → v68). v67 sent nothing until an
announcement arrived, and on HW nothing ever did: log
../logs/watch/2026-09-08-g0-v67-wf-caps-no-ready-ping.txt shows the pump reading fine (BG 129
mg/dL) with nowhere to send it. The watchface only calls send_ready() at init and from
pebble_app_connection_handler (../pebble-glucose-watchface/src/c/main.c:792 and :612), so
on a boot that starts in NORMAL the announcement goes out ~3 min before mode -> DUAL creates
the loopback, routes to the phone's Hybrid session, and is never repeated. Relaunching the
watchface fixes it (HW-confirmed both ways). Fix taken (C): with no target yet,
prv_push_bg_cb sends every field it has to the foreground watchface using the same claim rule,
and the first announcement narrows it permanently. A watchface that does not speak the protocol
ignores keys it doesn't know, and in practice anyone running this firmware has a glucose
watchface installed. Fixed alongside: the negative cache was cleared on any announcement, so
alternating watchfaces re-NACKed a known non-match on every switch (returning to a watchface
relaunches it — there is one app slot); it now clears only for the matching UUID.
The (E) alternative, and why the v19 objection does not apply to it. v19 backed out
PEBBLE_BT_CONNECTION_EVENT, which drives the system connection banner
(settings/bluetooth.c:225). The watchface's bluetooth_callback is a different path, hanging
off PEBBLE_COMM_SESSION_EVENT (connection_service.c:28-39) — and that event already
fires when the loopback opens: comm_session_open emits it (session.c:221) with
is_system = (destination != TransportDestinationApp). Ours is App-destination (deliberately,
so it doesn't evict the phone session), so is_system is false and it routes to
pebblekit_connection_handler, which the watchface doesn't subscribe. The two real options were
E1, emit an extra event with is_system = true — rejected, since that event also drives
debounced_connection_service, put_bytes, and an app_run_state_send_update to the phone
(event_loop.c:516-527), and in DUAL there is a real phone session to confuse; and E2, have
the watchface subscribe pebblekit_connection_handler too — one line, zero firmware risk, worth
doing in Morten's own watchface so the announcement arrives promptly on a mode toggle, but not a
substitute for C, which is what makes an arbitrary third-party watchface work.
**v68 on HW (2026-09-08, log ../logs/watch/2026-09-08-g0-v68-wf-caps-fallback-ready-ping-ok.txt):
works — mode -> DUAL 08:34:15, wf sender up, wf ready ping 08:34:17, BG 100 mg/dL at
08:34:43. Note what that does and does not prove: the announcement path is verified, the
fallback never fired. Mode is NORMAL on boot and the only way into DUAL is the spike app, so
every entry into DUAL is followed by exiting to the watchface, which relaunches it and
re-announces (one app slot). The fallback is therefore near-dead code in normal use — kept as
insurance for a watchface that doesn't announce at init, and for any future path that enters
DUAL without leaving the watchface (persisted mode across reboot, a button shortcut). It also
explains why v67 looked broken: the log dump happened while still in the app, before the
relaunch that would have fixed it.
Still unsent: DELTA_STRING (12), TREND_ARROW (13), SENDER_BATTERY (16), GRAPH_HIGH_LINE
(31), GRAPH_LOW_LINE (32). Delta and trend have no pump source — the pump gives neither, so both
would have to be derived from MinimedGraph the way the bridge does it (2-point extrapolation,
see the trend-arrow-soak note). GRAPH_HOURS now selects the serialized graph window; PebbleOS
retains up to 24 hours plus a 30-minute margin. Faster-than-five-minute sources are still
limited by the 300-point buffer.
- Three things the protocol spec itself needs (
../pebble-glucose-protocol/, propagate to all three implementations — watchface, bridge, PebbleOS — and reflash both sides together):
- A magic number. Identifying a glucose watchface currently leans on value constraints
(version == 1, capability word non-zero with no undefined bits), because keys 0 and 1 are the
most likely keys for any hand-rolled watchface to use. The SDK auto-assigns declared
messageKeysfrom 10000 (sdk/waftools/process_message_keys.py:95), so the collision population is only watchfaces using raw low keys — but a MAGIC value in a reserved key (3-9) would make the test exact instead of probabilistic. - Warn senders that
pebble_app_connection_handlerdoes not fire for them. An on-watch or otherwise App-destination sender appears as a PebbleKit-class connection, so a watchface subscribing onlypebble_app_connection_handler(the obvious choice, and what the reference watchface does) never gets its reconnect trigger. Watchfaces should subscribe both handlers; senders should not assume the announcement gets re-sent on session open. See item 10. - Margin semantics: resolved. The protocol now allows a sender to include a reasonable
margin before the requested
GRAPH_HOURSwindow; PebbleOS uses 30 minutes.
../Documentation/— protocol source of truth. Update eagerly with confirmed facts.../minimed-pebble-bridge/glycemicgpt/plugins/shipped/medtronic/— the working Kotlin impl to port (peripheral, SAKE, reads, history parser) + tests.../xdrip-pebble-archive/PythonPumpConnector/PythonSake/pysake/— readable SAKE reference + public test vectors (constants.py).- Do NOT comment on GitHub/upstream — Morten does that himself (plans to update PebbleOS #853).