O5 pod simulator support #17
Open
jwoglom wants to merge 10 commits into
Open
Conversation
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.
Test❌ failed to pair Code ReviewThe code review is beyond my knowledge. I simply did a test ConfigurationTest phone running Loop-TP v3.15.0 (tidepool-merge-add_omnipodkit)
Test NarrativerPi 3bUse rPi 3b as the simulator. delete the pod folder and copy over fresh from Mac the branch: claude/o5-port-synthesis-eCiOB Issue commands on rPi: then Attempt to pair DASH and get No pods found. Try all my tricks (reboot, rebuild, etc) and no joy. For dash tried: For O5 tried: In case this is an rPi 3b problem, try this on my rPi 4. rPi 4go version go1.22.3 linux/arm64 bring over claude/o5-port-synthesis-eCiOB still No pods found. Back to you @jwoglom |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.