Skip to content

Omnipod Five simulator v2 - #24

Open
jwoglom wants to merge 14 commits into
LoopKit:mainfrom
jwoglom:omnipod-five-v2
Open

jwoglom wants to merge 14 commits into
LoopKit:mainfrom
jwoglom:omnipod-five-v2

Conversation

@jwoglom

@jwoglom jwoglom commented Oct 23, 2026 •

Copy link
Copy Markdown

Port and improve upon the existing, but only partially-working O5 simulator which was forked from this repo earlier this year during O5 OmnipodKit development.

  • add -mode flag for selecting either o5 or dash -- still defaults to dash. The mode is saved in state.toml, so a restart keeps the pod the phone paired with (a different -mode is ignored with a warning; -fresh makes a new pod)
  • O5 link layer: 244-byte packets with no RTS/CTS, the O5 advertisement (CE1F923D-C539-48EA-7300-0A<controller id>00, 02 as the last byte on a fault) and data characteristic 1A7E2443, signed (type 4) messages, and the Omnipod CRC16 on responses
  • O5 pairing as OmnipodKit's O5LTKExchanger runs it (SPS0, P-256 SPS1, SPS2.1/SPS2 with certificates and signatures), then the AID setup commands; bolus, basal, temp basal, cancel and deactivate must be signed, or the pod answers error 0x2A as a real one does; version responses report product id 5
  • add helper (cmd/o5keypair) for generating an O5Data.swift file which defines simulated certificate data accepted by the simulator backend so the actual pairing process can occur
  • the control panel and GET /status show the mode, the controller and how many signed commands were rejected
  • Linux Bluetooth (Raspberry Pi): the pod logs the ATT MTU the phone negotiates, and asks for 256 when it is under the 247 an O5 packet needs (an iPhone offered 185, so SPS2.1 was cut short); and each L2CAP payload's fragments are now written together, as concurrent CMD/DATA notifications interleaved and the phone dropped both
  • Upgrade golang.org/x/sys library: the 2020 version crashed on its first log line on macOS with current Go. The September 2021 version is the newest that builds at this module's go 1.15, which is unchanged
  • rebased on Answer a stale EAP-AKA SQN with a synchronization failure #25: EAP-AKA uses its CheckSqn; this adds a random pod IV per session and a test of CheckSqn with the AUTN OmnipodKit sent
  • docs: docs/O5_SIMULATOR_VALIDATION.md (Loop in the iOS Simulator), docs/BLE_E2E_TEST.md (over-the-air test on Linux), docs/O5_PORT_PLAN.md (protocol details and their OmnipodKit sources)

Tested with ImpossiBLE with Loop on an iOS simulator alongside the pod simulator in O5 mode.

Testing

  • Loop in the iOS Simulator (LoopWorkspace next-dev, ImpossiBLE): O5 pairing with the signatures verified both ways, priming, cannula insertion, bolus, temp basal, suspend and resume, Read Pod Status, an alert and an occlusion fault from the control panel, deactivation; no signed command rejected, no AES-CCM or CRC errors. DASH: pairing through deactivation. Reconnecting after Loop relaunches needs an ImpossiBLE fix that is in jwoglom/g7sim (impossible-patches/0007) and not yet in LoopKit/g7sim. This run was before the rebase on Answer a stale EAP-AKA SQN with a synchronization failure #25.
  • Over the air on Linux (docs/BLE_E2E_TEST.md, after the rebase): the pod binary on one virtual Bluetooth controller, the Go model of OmnipodKit's phone on another, acting as an iPhone that offers an MTU of 185. An O5 pod pairs and runs (session, AID setup, SetupPod, status, an unsigned bolus refused, a signed bolus); DASH is unchanged; all 8 scenarios do what they should. The pod before the MTU request fails as a Pi did with that iPhone. Whether a real iPhone grants the pod's MTU request is still to be confirmed on a Pi.
  • go test ./... with Go 1.21 (as CI) and 1.27, including a Go model of OmnipodKit's phone that runs the whole O5 and DASH flows in memory.

Notes for reviewers

  • vendor/github.com/paypal/gatt carries in-place patches (128-bit UUID and manufacturer data advertising, the MTU request, the fragment lock). go mod vendor overwrites them, so restore those files after re-vendoring.
  • Outside vendor/ the diff is 52 files (+5,058/−394). In vendor/, everything but those gatt files and modules.txt is the mechanical x/sys update.
  • The over-the-air test runs only with the ble_e2e build tag, so CI skips it.

claude and others added 14 commits October 26, 2026 20:25
Lays out what OmnipodKit (the authoritative O5 driver) expects of a pod,
why the old five branch no longer pairs with it, and which package owns
each O5 difference on this branch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoKwZHxWpWRGKw3KNrgZfn
The simulator can play an Omnipod DASH (the default) or an Omnipod 5.
The mode is fixed when the pod is created with -fresh and saved in
state.toml, so a restart without -mode keeps the pod the phone paired
with; a differing flag is a warning. The link layer and the status page
know the mode; nothing else changes yet.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoKwZHxWpWRGKw3KNrgZfn
The link layer now follows the pod's mode (OmnipodKit BlePodProfile,
PodAdvertisement, PeripheralManager+OmnipodKit):

* One split/join implementation, a port of OmnipodKit's PayloadSplitter
  and PayloadJoiner, with a 20-byte layout for DASH and a 244-byte one
  for an Omnipod 5. The DASH packets are what the simulator always
  wrote, except that the last one is zero padded as a pod's is; a packet
  out of order is NACKed with the index the pod expected.
* An Omnipod 5 phone writes its packets without RTS/CTS, so in O5 mode
  the first data packet starts a message and the pod writes its own
  packets straight away; SUCCESS/NACK/FAIL stay.
* An Omnipod 5 advertises one 128-bit UUID carrying the controller id
  (FFFFFFFE until paired, last byte 02 on a fault) and serves its data
  on characteristic 2443. Both radios do this; the vendored paypal/gatt
  learns to advertise a 128-bit UUID under its own AD type and to add
  manufacturer data. DASH advertising is unchanged byte for byte.
* Message type 4 (encrypted and signed) is parsed, with its 64-byte
  signature, and the 11-bit length no longer truncates over 255 bytes.
* The Omnipod message CRC16 and CRC-16/XMODEM, which the O5 protocol
  uses, with OmnipodKit's test vector.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoKwZHxWpWRGKw3KNrgZfn
In O5 mode the pod runs OmnipodKit's O5LTKExchanger exchange: SPS0
(algorithm 09 with a CRC-16/XMODEM), SPS1 with P-256 keys, then SPS2.1
and SPS2 under the conf key both sides derive with SHA-256 over
FIRMWARE_ID, the public keys and the ECDH secret. SPS2.1 carries each
side's certificate and SPS2 its certificate and a signature over the
171-byte channel-binding transcript, AES-CCM encrypted with the
per-direction counting nonces. The pod signs with a persistent P-256
identity in a self-signed certificate, which is all OmnipodKit reads,
and keeps the phone's key from its SPS2 certificate for the signed
(type 4) commands that follow. The DASH exchange is unchanged; a test
checks it against OmnipodKit's DashKeyExchange, and another runs the
whole O5 pairing against a Go model of the phone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoKwZHxWpWRGKw3KNrgZfn
* The pod pairs with the O5 exchange in O5 mode, keeps a persistent
  signing identity, takes the phone's id from its HELLO and advertises
  it once paired.
* Between AssignAddress and SetupPod an Omnipod 5 phone sends plain-text
  AID setup commands (UTC, TDI, target profile, DIA, EGV, insulin
  history); pkg/aid parses and answers them with the values the phone
  set, checked against OmnipodKit's O5CommLogFixtures.
* Insulin, cancel and deactivate commands must be signed (type 4) on an
  Omnipod 5; the signature over header and ciphertext is checked with
  the phone's key from SPS2, and an unsigned or badly signed one gets
  error 0x2A as a real pod answers.
* The two version responses are built from the pod's identity, so an
  Omnipod 5 reports product id 5, its firmware and lot (OmnipodKit's
  captured values); DASH bytes are unchanged. O5 responses carry the
  Omnipod CRC16, which OmnipodKit verifies for O5 pods.
* EAP-AKA draws a random pod IV each session (main checks AUTN in CheckSqn).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoKwZHxWpWRGKw3KNrgZfn
…e mode

A Go model of OmnipodKit's phone pairs with the pod over an in-memory
link (DashLTKExchanger or O5LTKExchanger), opens an EAP-AKA session,
sends AssignAddress, the Omnipod 5 AID setup, SetupPod, a status read
and a bolus, checks the advertisement the pod switches to, and
reconnects for a new session. On the Omnipod 5 an unsigned bolus is
refused with 0x2A and a signed one runs; its responses carry a valid
CRC16. The control panel names the pod's mode and controller, and the
README documents -mode o5.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoKwZHxWpWRGKw3KNrgZfn
docs/O5_SIMULATOR_VALIDATION.md walks a Mac through running Loop
(LoopKit/LoopWorkspace next-dev, whose OmnipodKit talks to a simulated
pod when ImpossiBLE is present) against this pod in -mode o5 through
ImpossiBLE, and what to look for. cmd/o5keypair makes the Omnipod 5
registration data OmnipodKit needs before it will pair, as the
o5keypair JSON its import takes or as an O5Data.swift that compiles the
data into OmnipodKit; App Attest, which fetches real data, does not
work in the Simulator. The data pairs with the simulator only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VoKwZHxWpWRGKw3KNrgZfn
Built with current Go on macOS, the pod crashed with SIGSEGV on its
first log line: logrus's terminal check calls x/sys's ioctl, and the
2020 x/sys hands libSystem a bad function pointer under the current
toolchain. The September 2021 x/sys calls libSystem through trampoline
addresses instead and is the newest that builds at this module's go
1.15 (later ones use unsafe.Slice). Only x/sys is re-vendored; the
patched vendor/github.com/paypal/gatt files are kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YkbWyt285vXZMpvHCbpLq
main's CheckSqn now reads the controller's SQN from AUTN and verifies
MAC-A over it, which this commit did for the warning it replaced; what
is left is its test, on the AUTN OmnipodKit sent in its first session
after pairing with the simulator: it verifies with SQN 1, is fresh
after a pairing, stale once 1 has been used, and does not verify with a
corrupted MAC-A.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YkbWyt285vXZMpvHCbpLq
An Omnipod 5 packet is 244 bytes, so the phone's writes need an ATT MTU
of at least 247. The phone proposes the MTU, and paypal/gatt accepts it
up to 256; below 247, single-packet messages still get through but a
longer one (such as SPS2.1 when pairing) never arrives whole, and the
phone times out waiting for SUCCESS with nothing in the pod's log. The
Linux radio now logs the MTU when the DATA notifications are set up and
whenever it changes, and warns when the pod's packets do not fit it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YkbWyt285vXZMpvHCbpLq
The pod saves state.toml once it reads the phone's ACK, which can come
after the test already has its response, so t.TempDir's cleanup could
race that save and fail with "directory not empty" (about 7% of runs of
TestAPairedPodTakesANewSession, which ends on a command). startPod now
registers a cleanup, run before t.TempDir's, that waits for the pod's
lock: the pod holds it from a command until the save after its ACK. It
gives up after 3 s, as a test that failed mid-exchange can leave the pod
waiting for an ACK. 0 failures in 1,200 runs, with Go 1.21 and 1.27.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YkbWyt285vXZMpvHCbpLq
An iPhone pairing with the pod over Linux Bluetooth offered an ATT MTU
of 185, so the phone's writes held 182 bytes and the Omnipod 5's
244-byte packets were truncated: SPS2.1 never arrived and pairing
timed out. The phone starts the exchange as it connects, but either
side may start one as the ATT client. In O5 mode the pod now waits for
the phone's exchange and, if the MTU is still under 247, asks for 256
(the most paypal/gatt takes); the phone answers with its own Rx MTU and
the MTU becomes the smaller of the two. The vendored paypal/gatt gains
RequestMTU, takes the MTU response, and no longer answers an ATT error
response with one of its own. DASH is unchanged. Not yet tried with a
phone: the MTU logged after this exchange shows whether iOS accepts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YkbWyt285vXZMpvHCbpLq
paypal/gatt cuts every L2CAP payload into 27-byte HCI ACL fragments
(its bufSize) and wrote them without a lock. The pod notifies DATA and
CMD from different goroutines, so with an Omnipod 5's 247-byte
notifications the SUCCESS on CMD landed between the fragments of a DATA
packet; the phone's controller dropped both ("Unexpected start frame"),
and pairing stopped at the pod's SPS2.1. DASH packets fit one fragment,
which is why it never showed. Each payload's fragments are now written
under a per-connection lock.

Found with an over-the-air test in a Linux VM (two btvirt controllers,
the pod on one, OmnipodKit's phone model on the other): with this and
the MTU request, an Omnipod 5 pairs, takes a session, the AID setup and
a signed bolus where the phone offers an MTU of 185; without it, the
pod's reply never arrives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YkbWyt285vXZMpvHCbpLq
TestOverTheAir (build tag ble_e2e, so go test ./... and CI skip it) runs
the phone model from phone_test.go against the pod binary on another
Bluetooth controller, over the Linux Bluetooth stack: the path an iPhone
takes to a pod on a Raspberry Pi. The phone starts its own MTU exchange
and answers the pod's as a scenario says, and cuts writes to MTU-3
bytes as iOS does. scripts/ble-e2e provisions an Ubuntu VM (Lima on a
Mac) with BlueZ's btvirt, builds the binaries and runs eight scenarios
against what each should do; docs/BLE_E2E_TEST.md explains it and the
emulator's quirks the scripts work around. It found the two fixes
before this one: all eight pass here, and the pod at ba86841 fails as a
Pi did with an iPhone offering an MTU of 185.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013YkbWyt285vXZMpvHCbpLq
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.

2 participants