Skip to content

O5 pod simulator support #17

Open
jwoglom wants to merge 10 commits into
LoopKit:mainfrom
jwoglom:claude/o5-port-synthesis-eCiOB
Open

O5 pod simulator support #17
jwoglom wants to merge 10 commits into
LoopKit:mainfrom
jwoglom:claude/o5-port-synthesis-eCiOB

Conversation

@jwoglom
Copy link
Copy Markdown

@jwoglom jwoglom commented May 24, 2026

No description provided.

claude added 10 commits May 24, 2026 11:15
Add Mode enum (Dash, O5) and a -mode CLI flag (default "dash" so existing
users see no behavior change). Plumb the selected mode through Pod into
the Pair instance constructed in StartActivation.

Add the Omnipod 5 KDF: SHA-256 over a length-prefixed buffer of
FIRMWARE_ID || 0 || pdmPublic || podPublic || sharedSecret, split into
16-byte conf and 16-byte ltk. In computePairData, when Mode == O5,
derive (conf, ltk) via the new KDF and skip the Dash CMAC chain entirely.

The KDF expects 64-byte raw P-256 public keys; real P-256 key plumbing
arrives with the SPS2 work in a later commit. Tests in o5kdf_test.go
exercise the KDF in isolation with synthetic 64-byte fixtures.

Source: jwoglom/five commit 6e2fa66 (Step 1).
Embed BTSNOOP captures from real Omnipod 5 pairing sessions as Go test
fixtures. The new pkg/testfixtures package exposes Captures(), returning
three deterministic PairingCapture values with SPS1/SPS2.1/SPS2 byte
strings from two different pod sessions. These will be consumed by SPS2
and Type-4 verification tests in later commits.

Bump go.mod 1.15 -> 1.20 (go:embed requires 1.16+; 1.20 matches the rest
of the porting work). go.sum already had the needed entries from prior
vendoring; vendor/modules.txt picks up the explicit marker for the two
indirect modules that Go 1.17+ requires to be declared.

Source: jwoglom/five commit 9551b13 (Step 0).
Add pkg/bluetooth/packet — a platform-neutral implementation of the
OmnipodKit BLE packet split/join wire format. Split() emits properly
framed fragments (single-packet, first/middle/last/optional+1) and
Join() reassembles them using each fragment's actual length, so smaller
negotiated MTUs (down to ~30 bytes) work without code changes. Tests
cover the round-trip, CRC tamper detection, index mismatch rejection,
the exact-fit boundary, and shorter fragments.

Fix the TWi length-decode bug in pkg/message: `data[6]<<3 | data[7]>>5`
was a uint8 operation, silently truncating any payload longer than 255
bytes (an SPS2.1 frame at 651 bytes decoded as 139). Cast both halves to
uint16 before shifting; update the three slice expressions to take int(n)
so the type widens cleanly. New length_test.go pins this at SPS2.1 size
and at the 11-bit field maximum (2047 bytes).

Add MessageTypeEncryptedSigned = 4 constant so later commits don't
re-touch this file just to declare it. Full Type-4 Marshal/Unmarshal
plumbing arrives in a later commit.

bluetooth.go still uses its existing I/O paths; the swap to packet.Split
/Join happens in the next commit.

Source: jwoglom/five commits a472229, a9bdabd, 0be4858 (TWi fix).
Layer the Omnipod 5 BLE shape onto current main's bluetooth.go without
regressing main's transport fixes from 79be48c. Three things change:

1. Advertising / GATT services (from five 9ea07e3):
   - Co-advertise ECF301E2-... alongside CE1F923D-... so OmnipodKit's
     scanner discovers the simulator.
   - Heartbeat service 7DED7A6C-... with notify characteristic
     7DED7A6D-..., backed by a 10-second keep-alive goroutine that fires
     a one-byte notification once a central subscribes.
   - GATT data characteristic UUID corrected to canonical OmnipodKit
     form (1A7E2443-...).

2. Packet split/join I/O (from five a472229): the loop now dispatches to
   three channels — outgoing messages go through writeMessageData (using
   packet.Split), inbound data fragments go through readMessageData
   (using packet.Join driven by a closure that pulls subsequent
   fragments from b.ReadData()), and the legacy cmdInput path is
   retained for the existing fragmentation handshake.

3. cmdActivation channel scaffolding: the channel is declared and
   allocated, but ReadCmd still reads from cmdInput exactly like main.
   The HELLO-vs-RTS routing fix (76fd556) that feeds cmdActivation
   arrives in Commit 9; the channel exists now so that commit doesn't
   have to touch the struct definition.

What was deliberately preserved from main (79be48c et al.):
- writeMessage's integer arithmetic for large status responses
  (fullFragments / rest / index as int with byte() conversions) — five
  silently regressed this back to byte arithmetic.
- StopMessageLoop call in CentralConnected (main's 97ce95d revert; five
  branched before that).

Deviation forced by environment: AdvertiseNameServicesMfgData is not
present in the vendored paypal/gatt on this branch (five carries a
vendor patch we are not pulling in). Falls back to
AdvertiseNameAndServices in both initial advertise and refresh paths;
the manufacturing-data payload is therefore not advertised.

Source: jwoglom/five commits 9ea07e3 (advertising/heartbeat), a472229
(packet split/join), plus partial 76fd556 (channel scaffolding only).
Add the SPS2.1 + SPS2 cryptographic exchange used by Omnipod 5 pairing,
plus the synthetic pod-side PKI that backs it.

New files in pkg/pair:
- podidentity.go: P-256 keypair + self-signed X.509 cert. NewPodIdentity
  generates fresh material; LoadPodIdentity rehydrates from persisted
  raw scalar + DER. Each simulator instance gets a stable identity on
  first activation.
- spsnonce.go: 13-byte AES-CCM nonce builder with separate
  read/write counters keyed off the SPS exchange's nonce halves.
- sps2.go: AES-CCM encrypt/decrypt for SPS2.1 (cert) and SPS2
  (cert||sig), 171-byte channel-binding transcript builders for both
  PDM and pod directions, SHA-256+ECDSA sign/verify with raw 64-byte
  r||s pack/unpack, and a P-256 SPKI extractor that reads the public
  key out of a DER cert without doing chain validation. Also includes
  VerifyType4Signature, which lands here pre-wired but is unused until
  the Type-4 framing commit.
- sps2_test.go, spsnonce_test.go: round-trips, transcript binding,
  P-256 extraction.

pair.go hand-merge (preserve Dash bit-for-bit):
- Pair struct gains nonceState, identity, pdmCertDER, sps21Data.
- SetIdentity / IsO5 accessors.
- computeMyData(): Dash unchanged (32-byte Curve25519 + zeroed nonce).
  O5 mints a real P-256 keypair via crypto/ecdh and a 16-byte random
  nonce.
- computePairData(): Dash CMAC chain unchanged. O5 does P-256 ECDH
  (0x04||pdmPublic), feeds o5DeriveKeys with the now-matching 64/64/32
  inputs, and initialises nonceState.
- ParseSPS1 branches on Mode for the wire public-key length (32 vs 64).
- ParseSPS2 / GenerateSPS2: O5 path decrypts/encrypts cert||sig,
  builds transcripts using pre-decrypt nonce state, signs with the
  identity key, and soft-fails PDM signature verification (warns but
  doesn't fatal — transcript layouts can shift across firmware).
- ParseSPS21 / GenerateSPS21: new entry points; O5 decrypts/encrypts
  the intermediate-CA cert. Dash returns a zero-fill stand-in.

pod.go:
- ensurePodIdentity loads from state if persisted, else mints and saves.
- StartActivation renames the local pair value to pairCtx (so calls
  like pairCtx.IsO5() and pairCtx.SetIdentity() don't fight the package
  identifier), calls SetIdentity for O5, and inserts the SPS2.1
  send/receive between SPS1 and SPS2 — gated on IsO5(), so the Dash
  pairing sequence is unchanged.

state.go: adds O5PrivateKey + O5CertDER (TOML keys o5_private_key /
o5_cert_der) so the pod identity survives across runs.

Source: jwoglom/five commit b459d39 (Step 3). The copy of sps2.go
reflects five's HEAD (includes VerifyType4Signature), which the next
commit will wire up.
Add the pkg/aid package: parses and responds to the nine Algorithm
Integration Device (AID) Phase-1 setup commands that fire between
AssignAddress and SetupPod during O5 pairing (UTC time, TDI, target BG
profile, DIA, EGV, three batches of insulin history, status queries).

AID commands are plain-ASCII payloads carried inside the same AES-CCM
Type-1 encrypted transport as standard commands — they are NOT SLPE
wrapped, so they require their own dispatcher. The aid.IsAIDPayload
function distinguishes them by checking for a non-"0" feature prefix;
aid.Parse decodes them length-anchored so the trailing ",G<f>.<a>"
suffix on SET+GET commands works binary-safely; cmd.BuildResponse
emits canned Gen1 Status (28B), Unified Status (29B), echoes data on
SET+GET, and returns "0" ack for Extended SET.

pod.CommandLoop gets a routing branch after decryption: when
aid.IsAIDPayload matches, hand off to handleAIDCommand and continue —
the standard command.Unmarshal path is untouched. The branch is gated
purely on payload shape, so Dash sessions (which never send AID
commands) flow through exactly as before. handleAIDCommand encrypts
the response via the existing encrypt.EncryptMessage path, sends it,
and reads the controller's empty-payload ACK to keep nonces in sync,
mirroring the standard post-response flow at the bottom of
CommandLoop.

Note: handleAIDCommand deliberately does NOT call notifyStateChange()
while holding p.mtx — the CommandLoop's existing post-unlock call
handles that. This matches the post-acbfbb2 layout (the deadlock fix
that lands later in a runtime-fixes commit). e4bd7c3 already shipped
the fixed form, so it's ported verbatim.

Source: jwoglom/five commit e4bd7c3 (Step 4).
Add the Type-4 (MessageTypeEncryptedSigned) wire format used by the
Omnipod 5 controller for post-pairing commands. Layout:

  [16-byte TWi header][ciphertext (plaintext-len + 8-byte CCM tag)]
                     [64-byte ECDSA r||s signature]

The header's 11-bit length field reflects only the ciphertext, NOT the
trailing signature.

pkg/message/message.go:
- Add Signature []byte to the Message struct.
- Marshal: extend the EncryptedPayload fast-path that returns m.Raw to
  also cover Type-4 (m.Raw already includes the appended signature).
- Unmarshal: new Type-4 branch slices ciphertext as data[16:16+n+8] and
  signature as the next 64 bytes; errors on truncation.
- Widen the type-range guard to allow MessageTypeEncryptedSigned.
- Fix flag.set: the old form was a no-op when val=false, leaving leftover
  bits set on reused *flag values. Without this, the type-4 bit pattern
  could mangle into type-5 when a writer reused a flag buffer. Now clears
  bits via *f &^= mask.

pkg/pair/pair.go: cache the PDM's 64-byte raw P-256 public key (X||Y,
left-zero-padded) at ParseSPS2 time by running extractP256PublicKey on
the PDM cert. PDMPublicKey() exposes it.

pkg/pod/state.go: PDMPublicKey []byte (TOML pdm_public_key) so the
cached key survives across simulator runs.

pkg/pod/pod.go:
- StartActivation: after LTK is set, copy pairCtx.PDMPublicKey() into
  state and save.
- CommandLoop: before decryption, if msg.Type == MessageTypeEncryptedSigned,
  call pair.VerifyType4Signature(pdmKey, msg.Raw[:16], msg.Payload,
  msg.Signature). Soft-fail: warn on missing pubkey / malformed sig /
  verify error / failed verification; log success otherwise. Never fatal,
  always continue with decryption — transcript layouts can drift across
  firmware revisions.

Tests: TestUnmarshalType4 + TestUnmarshalType4Truncated for the wire
layout, TestVerifyType4Signature_RoundTrip + bad-input rejection for
the verification helper.

Source: jwoglom/five commit 34fc6ad (Step 5).
Land the plumbing for mode-aware SetUniqueID (0x03) and GetVersion (0x07)
responses without changing any byte output today. When the real Omnipod 5
byte captures arrive from Joe, swapping the O5 constants will be a
one-line change with the regression tests already in place to catch any
accidental Dash drift.

Plumbing:
- pkg/pod/state.go: persist Mode (pair.Mode) in PODState (TOML key mode).
  Pod.New writes state.Mode = pairMode on every launch so the -mode flag
  is the source of truth for the current run.
- pkg/command/command.go: optional ResponseForMode interface that any
  Command can implement to return mode-specific bytes. Existing Command
  interface is unchanged so the change ripples nowhere else.
- pkg/pod/pod.go CommandLoop: when cmd.IsResponseHardcoded(), type-assert
  for ResponseForMode and prefer it; fall through to GetResponse() for
  every other hardcoded command.
- pkg/command/{setuniqueid,getversion}.go: implement GetResponseForMode,
  threading the Mode through to the response struct.
- pkg/response/{setuniqueidresponse,versionresponse}.go: add Mode field,
  split the hex constant into named dash*ResponseHex + o5*ResponseHex
  (today identical, TODO(joe) comment marks the swap point). Marshal
  selects on Mode; zero value (ModeDash) preserves legacy behaviour for
  any caller still constructing the struct with bare {}.

Tests pin both Dash hex strings against the captured byte sequences on
current main and verify the zero-value path is Dash, so any future change
to the legacy constants must consciously update the regression. O5 tests
mirror the Dash bytes today with parallel TODO(joe) markers so the
assertion will be updated alongside the real-bytes swap.

Dash bit-for-bit unchanged.
Bundle four small upstream runtime fixes needed for O5 sessions to
survive past pairing.

bluetooth.go (76fd556 — HELLO routing):
- The CMD-char write handler now classifies every write: multi-byte
  payloads (e.g. the OmnipodKit HELLO frame 06 01 04 + 4-byte ID) and
  non-RTS single-byte signals go to cmdActivation; the four
  fragmentation-control signals (RTS / SUCCESS / NACK / FAIL) stay on
  cmdInput.
- ReadCmd reads from cmdActivation again, completing the dispatcher
  wiring that Commit 3b scaffolded.
- The legacy RTS path's first-byte check no longer fatals on an
  unexpected value — it warns and returns nil. Misclassified future
  signal bytes can't kill the loop.

bluetooth.go (b674a06 — clean shutdown):
- ShutdownConnection now calls StopMessageLoop() so the idle-timeout
  reconnect can re-init the pipeline without the "Messaging loop is
  already running" fatal.

pod.go (acbfbb2 — AID deadlock):
- The AID branch in CommandLoop now calls notifyStateChange() AFTER
  p.mtx.Unlock(). handleAIDCommand itself still must not call it while
  holding the mutex (documented). Without this notification, web clients
  watching state wouldn't see AID-phase progress at all.

pkg/pod/delivery (4ce6046 — pulse-by-pulse math):
- New package with PartialPulses(start, end, totalPulses, now) — pure
  interpolation math that's testable on macOS without the gatt
  dependency. delivery_test.go covers 2-second/pulse user boluses,
  1-second/pulse setup boluses (prime/cannula), and the degenerate
  boundary cases.
- Not wired into the live bolus accounting today: main's existing
  immediate-decrement model (BolusRemaining + Delivered) is preserved
  bit-for-bit. The package is available as a supplemental helper for
  future work (e.g. WS API exposure of pulse-level delivery progress).

Prime pulse cadence (b674a06 in pod.go): already implemented by main's
existing `if PodProgress >= PodProgressRunningAbove50U` branch
selecting 1s/pulse during setup vs 2s/pulse during user boluses. No
edit was needed — the b674a06 snapshot pattern expresses the same rule
in the snapshot-bolus model that we didn't adopt.

Source: jwoglom/five commits 76fd556, acbfbb2, b674a06, 4ce6046.
Replace the placeholder o5*ResponseHex constants (which mirrored Dash)
with the actual byte streams captured from a real Omnipod 5 pod (Pod
Type ID 05, firmware 9.0.4, BLE firmware 5.0.2, Lot 261724721, TID
491153). Both files now carry the upstream byte-field layout comment so
the structure of each frame is self-documenting.

SetUniqueID (0x011B response to 0x03):
  01 1B 1388 10 08 34 0A 50 09 00 04 05 00 02 05 03 0F999A31 00077E91 00000000
  = 01 LL VVVV BR PR PP CP PL MXMYMZ IXIYIZ ID 0J LLLLLLLL TTTTTTTT IIIIIIII

VersionResponse (0x0115 response to 0x07):
  01 15 09 00 04 05 00 02 05 02 0F999A31 00077E91 05 FFFFFFFF
  = 01 LL MXMYMZ IXIYIZ ID 0J LLLLLLLL TTTTTTTT GS IIIIIIII

The Dash regression tests are unchanged and still pin the legacy bytes
verbatim; the O5 tests now assert the real captured hex. Mode-aware
plumbing (the optional ResponseForMode interface, the Mode field on
PODState, the type-assertion dispatch in CommandLoop) all landed in the
previous response commit — this is purely the constant + test swap that
commit was scaffolded for.
@marionbarker
Copy link
Copy Markdown

Test

❌ failed to pair

Code Review

The code review is beyond my knowledge. I simply did a test

Configuration

Test phone running Loop-TP v3.15.0 (tidepool-merge-add_omnipodkit)

  • confirm DASH pod from rPi pairs correctly when using a different rPi running main branch
  • ❌ could not get DASH or O5 pod to pair with this PR

Test Narrative

rPi 3b

Use rPi 3b as the simulator.

delete the pod folder and copy over fresh from Mac the branch: claude/o5-port-synthesis-eCiOB
build and test

Issue commands on rPi:

go build
sudo setcap 'cap_net_raw,cap_net_admin=eip' ./pod

then

./pod -q -fresh

Attempt to pair DASH and get No pods found. Try all my tricks (reboot, rebuild, etc) and no joy.
Attempt to pair O5 and get No pods found.

For dash tried:

./pod -q -fresh
./pod -q -fresh -mode dash

For O5 tried:

./pod -q -fresh -mode o5

In case this is an rPi 3b problem, try this on my rPi 4.
rPi 3b
go version go1.22.2 linux/arm64
restore to main branch for pod
confirm I can pair DASH pod again

rPi 4

go version go1.22.3 linux/arm64

bring over claude/o5-port-synthesis-eCiOB

still No pods found.

Back to you @jwoglom

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.

3 participants