July 29, 2026 · 9 min read
Bringing AiDEX X to Loop: From Protocol Analysis to a Swift Plugin
How I worked from an Android client and a BLE protocol toward a Python reference, a Rust core, the AidexKit Swift driver, and a working LoopWorkspace integration.
It started with getting data into Loop
I have recently been working on bringing the AiDEX X CGM into Loop on iOS.
The device already had its own Android app, but no ready-made iOS driver. At first, the path looked straightforward: understand the Bluetooth exchange and data layout, then write a CGMManager that passes readings to LoopKit.
Once I started implementing it, the questions quickly moved beyond parsing:
- Can the connection recover after the app restarts?
- Can real-time readings and history backfill overlap without duplicates or gaps?
- Will unknown device states prevent unreliable data from reaching Loop?
- Can the plugin build independently of my temporary local setup?
The work eventually settled into three repositories:
| Project | Responsibility |
|---|---|
microtech-protocol-analysis | Protocol evidence, the Python reference, the Rust core, and synthetic tests |
AidexKit | Swift protocol code, CoreBluetooth, Keychain, and the LoopKit plugin |
LoopWorkspace | Pinning the plugin version and validating the complete build and device integration |
I keep the actual project names and a few non-sensitive code excerpts in this article. Real device identifiers, credentials, glucose samples, private remote URLs, and commands that may change device state remain in the private research environment.
Protocol conclusions need provenance and executable proof
The work began in microtech-protocol-analysis. I kept the Android app analysis, existing captures, runtime behavior, and device logs there.
Early notes naturally contained statements such as “this might be a time index” or “this status appears to mean invalid.” After a while, it became difficult to tell whether a conclusion came from a device session, static analysis, or an interpretation that merely fit the current sample.
I eventually split the material into three layers:
docs/
reference/ Confirmed conclusions that can guide implementation
research/ Investigation notes, evidence levels, and open questions
planning/ Scope, risk, and acceptance criteria for the next stage
Fields are also handled according to their evidence:
| Evidence state | Representation in code |
|---|---|
| Confirmed | Stable model and business meaning |
| Inferred | Preserve the raw value and provenance without making a product promise |
| Unknown | Keep it raw or reserved, or fail closed |
For example, if a high nibble can be extracted from a payload but has no known consumer or device sample, I keep it as reservedHighNibble. I do not add a plausible-sounding enum case just to make the model look complete.
The most dangerous state in protocol work is often not “unknown,” but “almost understood.”
Python, Rust, and Swift each have a different job
Protocol conclusions first go into the Python reference. Python is quick to change and works well for offline replay and checking byte order, lengths, timestamps, history pages, and retry behavior.
Once edge cases became important, I added the pure Rust aidex-core. It does not own BLE, persistence, or platform lifecycle. It only handles bytes, protocol state, and typed errors.
AidexKit still uses native Swift. iOS is the only concrete consumer today, and introducing FFI too early would also introduce object-lifetime rules, error mapping, generated code, and framework release work.
The current split is:
| Implementation | Main value | Not its current role |
|---|---|---|
| Python | Fast protocol validation and offline replay | Production app SDK |
| Rust | Stronger boundaries through types and state machines | An FFI dependency for the current iOS driver |
| Swift | Real iOS lifecycle and Loop integration | Storage for raw research material |
These are not three unrelated sources of truth. Python and Rust consume the same synthetic vectors and compare both successful values and error categories. The Swift parsers are then implemented against that protocol baseline.
The Rust core moves conventions into types
The aidex-core boundary is deliberately small:
#![forbid(unsafe_code)]
pub mod command;
pub mod crypto;
pub mod error;
pub mod frame;
pub mod history;
pub mod model;
pub mod parser;
pub mod secret;
pub mod session;
Secrets are no longer plain Vec<u8> values. They are fixed-size types that cannot be formatted or serialized:
#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
pub struct PairKey([u8; 16]);
The crate also has compile-fail tests to ensure that debugging code like this is rejected:
let key = PairKey::new([0; 16]);
println!("{key:?}");
Once the Rust core passed its acceptance criteria, I stopped at the FFI decision point. Having a cross-platform core does not mean every platform should immediately depend on it.
The hard part of history backfill is cursor state
History backfill initially looked like ordinary pagination:
Read range → request page → persist → request next page
In practice, the difficult parts were the target, transaction boundary, and idempotency.
HistoryBackfill freezes the newest index for one pass. next_request() only selects a request; it never advances a cursor early. The caller must persist the data first and commit the page only after the transaction succeeds:
next_request
↓
Read and validate the page
↓
Persist records and the durable cursor in one transaction
↓
commit page
The main rules fit into a small table:
| Situation | Behavior |
|---|---|
| Page is shorter than usual | Continue toward the frozen target; do not infer EOF |
| Page has a gap or partial overlap | Reject it |
| Page is stale | Ignore it only after it matches durable data |
| Data is not durable yet | Do not advance the in-memory cursor |
| Connection drops | Rebuild from the durable cursor |
| Index wrap is unconfirmed | Fail closed |
If the cursor advances before the data reaches storage and the app exits in between, the next connection starts too far ahead and permanently skips that page.
That changed my definition of “backfill complete.” Reading every page is only the first half. The local history must remain continuous after retries and crashes.
AidexKit owns the real iOS lifecycle
AidexKit is split into four targets:
| Target | Responsibility |
|---|---|
AidexKitCore | Crypto, frames, parsers, and protocol models |
AidexKit | CoreBluetooth, Keychain, and CGMManager |
AidexKitUI | Setup, QR scanning, settings, and status UI |
AidexKitPlugin.loopplugin | Loop plugin entry point |
AidexKitCore can be tested without launching the full app. Scanning, background restoration, and plugin embedding remain platform and workspace concerns.
The hardest part of the Bluetooth layer was not the API. It was ownership.
Early versions allowed setup UI and the final manager to create separate central managers. On a real device, that quickly became messy: an old scan was still winding down while a new connection started, restored peripherals returned to the original delegate, and timeouts and pending requests had no single owner.
Each AidexCGMManager now owns exactly one CBCentralManager:
Scan or restore
→ connect
→ discover services and characteristics
→ restore credentials or complete first-time pairing
→ establish the session
→ read device state
→ backfill history
→ ready
The UI can display state and initiate actions, but it does not own a separate connection. A disconnect clears the session, pending request, and current backfill buffers while preserving the pair key needed across connections.
Credentials do not enter Loop rawState
LoopKit persists CGMManager.rawState. If every value needed for reconnection goes into that dictionary, a pair key may later leave the app through an Issue Report or debugging path.
AidexKit state stores only a credential reference and ordinary runtime state:
public struct AidexCGMManagerState: RawRepresentable {
public let credentialIdentifier: UUID
public var preferredPeripheralIdentifier: UUID?
public var sensorStart: Date?
public var lastProcessedTimeOffsetMinutes: UInt16?
public var connectionState: AidexConnectionState
}
The actual credential goes into Keychain and is restricted to the current device after first unlock:
attributes[kSecAttrAccessible as String] =
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
Removing the CGM from Loop also does not silently become a device unpair operation. The current implementation removes the manager reference while retaining a recoverable Keychain credential. Changing the binding state on the device needs its own UI, confirmation, and recovery path.
Parsable does not automatically mean publishable
After a real-time payload is decrypted, AidexKitCore still checks device state, history status, reserved bits, and the time offset:
public var passesConservativeSafetyGate: Bool {
glucoseIsValid
&& historyStatus == .normal
&& hasObservedNormalPackedMarker
&& status == 0
&& calibrationTemperature == 0
&& timeOffsetMinutes >= 60
}
Unknown states, implausible timestamps, and duplicate indexes do not become normal-looking NewGlucoseSample values. They produce .unreliableData or are ignored as duplicates.
The driver can map the seven trend categories to LoopKit GlucoseTrend, but the physical scaling of the rate is not confirmed. trendRate therefore remains nil.
Real-time and historical paths also need the same identity:
credentialIdentifier + sensorStart + timeOffsetMinutes
If the same minute arrives once through backfill and once through a real-time notification, Loop still recognizes one record. Real-time notifications received during backfill are buffered and published in index order afterward, so the two paths do not race to advance the cursor.
The integration is not complete until it runs in LoopWorkspace
AidexKit first entered LoopWorkspace as a sibling project because that made early debugging fast. Once the device flow worked, I moved it to a Git submodule so LoopWorkspace could pin an exact AidexKit commit.
The workspace remains responsible for a narrow integration path:
Pin AidexKit revision
→ add the project to the workspace and shared scheme
→ build all four targets
→ copy the loopplugin
→ verify embedding and signing from an empty DerivedData directory
The current validation boundary is:
| Confirmed | Still to validate |
|---|---|
| Discovery and QR-based setup | More iOS and firmware combinations |
| One first-time iOS pairing flow | Long background runs and system termination |
| Keychain persistence and restart recovery | Natural sensor replacement |
| Minute-by-minute real-time data in Loop | Physical units for trend rate |
| History recovery after an offline window | BLE heartbeat |
| Deduplication across real-time and history | Long-running shadow-mode behavior |
| Clean build and signing | Full validation before closed-loop use |
Seeing a reading appear in Loop for the first time was exciting, but one foreground run and a normal restart are not enough to claim stable support.
Stop conditions matter when working with AI agents
AI agents are useful for static searches, keeping documents synchronized, porting parsers, and adding boundary tests. But a request such as “keep improving the protocol” can easily turn broader scope into a false sign of progress.
I now put four things into a long-running Goal:
| Topic | Example |
|---|---|
| Sources of truth | Reference docs, research notes, and the Python implementation |
| Allowed scope | Static analysis, synthetic tests, and offline replay |
| Prohibited scope | Exposing credentials, inventing unknown fields, or operating the device automatically |
| Stop point | Wait for an FFI decision after the core is complete |
Once the Rust core was complete, the agent did not create a binding simply because it could. Once the available offline material was exhausted, it also did not keep searching the same files and repackage missing evidence as a new conclusion.
I used to focus mainly on whether an agent could keep working. This project made it clear that knowing when not to continue matters just as much.
Looking back
At the surface, this project adds another CGM plugin to Loop.
Most of the work, however, was deciding what each conclusion was ready for: explanation, testing, a platform driver, or an actual capability claim in Loop.
The three repositories ended up matching those boundaries:
microtech-protocol-analysis Preserve evidence and unknowns
AidexKit Own connection, recovery, and publication
LoopWorkspace Pin a version and validate the complete app
The Rust core was not forced into Swift. An unknown trend rate did not receive a convenient default. Removing a manager did not quietly turn into a device unpair. Reliable foreground notifications did not immediately enable BLE heartbeat.
Each of those choices can look like one unfinished step. In practice, they made the boundaries clearer.
The first reading appearing on screen only showed that the direction was probably right. When the same implementation could disconnect, restart, restore missing history, and build again from a clean workspace, it finally began to feel like something that could stand on its own.