Back to notes

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:

ProjectResponsibility
microtech-protocol-analysisProtocol evidence, the Python reference, the Rust core, and synthetic tests
AidexKitSwift protocol code, CoreBluetooth, Keychain, and the LoopKit plugin
LoopWorkspacePinning 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.

Development workflow from BLE protocol analysis and Python/Rust validation to Swift and Loop integration

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 stateRepresentation in code
ConfirmedStable model and business meaning
InferredPreserve the raw value and provenance without making a product promise
UnknownKeep 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:

ImplementationMain valueNot its current role
PythonFast protocol validation and offline replayProduction app SDK
RustStronger boundaries through types and state machinesAn FFI dependency for the current iOS driver
SwiftReal iOS lifecycle and Loop integrationStorage 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:

SituationBehavior
Page is shorter than usualContinue toward the frozen target; do not infer EOF
Page has a gap or partial overlapReject it
Page is staleIgnore it only after it matches durable data
Data is not durable yetDo not advance the in-memory cursor
Connection dropsRebuild from the durable cursor
Index wrap is unconfirmedFail closed
History backfill advances its durable cursor only after a successful transaction

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:

TargetResponsibility
AidexKitCoreCrypto, frames, parsers, and protocol models
AidexKitCoreBluetooth, Keychain, and CGMManager
AidexKitUISetup, QR scanning, settings, and status UI
AidexKitPlugin.looppluginLoop 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:

ConfirmedStill to validate
Discovery and QR-based setupMore iOS and firmware combinations
One first-time iOS pairing flowLong background runs and system termination
Keychain persistence and restart recoveryNatural sensor replacement
Minute-by-minute real-time data in LoopPhysical units for trend rate
History recovery after an offline windowBLE heartbeat
Deduplication across real-time and historyLong-running shadow-mode behavior
Clean build and signingFull 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:

TopicExample
Sources of truthReference docs, research notes, and the Python implementation
Allowed scopeStatic analysis, synthetic tests, and offline replay
Prohibited scopeExposing credentials, inventing unknown fields, or operating the device automatically
Stop pointWait 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.