2026年7月29日 · 约 8 分钟读完
把 AiDEX X 接进 Loop:从协议分析到 Swift 插件的一次实践
记录我怎样从 Android 客户端和 BLE 协议入手,逐步完成 Python 参考、Rust 核心、AidexKit Swift 驱动和 LoopWorkspace 真机集成。
一开始只是想让 Loop 收到数据
最近,我在尝试把 AiDEX X CGM 接进 iOS 上的 Loop。
设备原本有自己的 Android App,但没有现成的 iOS 驱动。刚开始时,我以为主线很简单:弄清蓝牙通信和数据结构,再写一个 CGMManager 把读数交给 LoopKit。
真正做下去以后,问题很快从“能不能解析”变成了:
- App 重启后能不能恢复连接;
- 实时数据和历史补传会不会重复或漏失;
- 未知状态出现时,能不能阻止不可靠数据进入 Loop;
- 插件能不能脱离本地临时环境独立构建。
最终落下了三个工程:
| 工程 | 职责 |
|---|---|
microtech-protocol-analysis | 协议证据、Python 参考、Rust core 和合成测试 |
AidexKit | Swift 协议实现、CoreBluetooth、Keychain 和 LoopKit 插件 |
LoopWorkspace | 固定插件版本,完成构建、嵌入和真机验证 |
下面保留实际工程名和部分非敏感代码,但不会出现真实设备标识、密钥、血糖样本、私有远端地址和可能改变设备状态的命令。
协议结论要能追溯,也要能运行
最早只有 microtech-protocol-analysis。Android App 的静态分析、已有抓包、运行时行为和设备日志都放在这里。
研究初期,文档里经常出现“这个位置可能是时间索引”“这个状态看起来表示无效”。时间一长,很难再判断一条结论是真机确认、静态分析,还是为了让当前样本对上而做的推测。
后来我把资料分成三层:
docs/
reference/ 已确认、可以指导开发的结论
research/ 调查过程、证据等级和剩余未知
planning/ 下一阶段范围、风险和验收条件
字段也按证据处理:
| 证据状态 | 进入代码后的做法 |
|---|---|
| 已确认 | 转成稳定模型和业务含义 |
| 推断 | 保留原始值和来源,不作产品承诺 |
| 未知 | 使用 raw、reserved 或失败关闭 |
例如某个 payload 高位能够被读出,但没有实际消费者和真机样本,我会保留为 reservedHighNibble,不会顺手补一个听起来合理的枚举名。
协议分析里最危险的不是完全不知道,而是“差不多知道”。
Python、Rust 和 Swift 各自做什么
协议结论先进入 Python 参考实现。Python 修改快,适合离线回放和验证字节序、长度、时间换算、历史页与重试行为。
写到边界条件以后,我又增加了纯 Rust 的 aidex-core。它不负责 BLE、数据库和平台生命周期,只处理 bytes、协议状态与类型化错误。
AidexKit 最终仍使用原生 Swift。当前只有 iOS 这一个明确消费者,过早增加 FFI 会同时带来对象生命周期、错误映射、生成代码和 framework 发布问题。
三种实现的分工是:
| 实现 | 主要价值 | 当前不是用来做什么 |
|---|---|---|
| Python | 快速验证协议结论和离线回放 | 正式 App SDK |
| Rust | 用类型和状态机收紧边界 | 当前 iOS 的 FFI 依赖 |
| Swift | 处理真实 iOS 生命周期并接入 Loop | 保存研究原始材料 |
它们不是三套完全独立的事实。Python 和 Rust 会读取同一组合成向量,比较成功结果和错误类别;Swift parser 再对照这份协议基线实现。
Rust core 把一些约定写进了类型
aidex-core 的模块边界很直接:
#![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;
密钥不再是普通的 Vec<u8>,而是固定长度、不能被格式化或序列化的独立类型:
#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)]
pub struct PairKey([u8; 16]);
crate 里还有 compile-fail 测试,确认这种调试代码不能通过:
let key = PairKey::new([0; 16]);
println!("{key:?}");
Rust core 通过验收以后,我停在了 FFI 决策点。已经写出跨平台核心,不代表应该马上让所有平台都依赖它。
历史补传真正难的是游标
历史补传最初看起来只是分页:
读取范围 → 请求一页 → 保存 → 请求下一页
真正实现时,我碰到的是目标、事务和幂等问题。
HistoryBackfill 创建时会冻结本轮最新索引。next_request() 只选择请求,不会提前推进游标。调用方必须先保存数据,事务成功后才能提交这一页:
next_request
↓
读取并校验页面
↓
事务保存数据和 durable cursor
↓
commit page
关键规则可以压缩成一张表:
| 情况 | 处理 |
|---|---|
| 页面比平时短 | 继续看冻结目标,不猜 EOF |
| 页面跳号或部分重叠 | 拒绝 |
| stale 页面 | 与本地数据一致后才能幂等忽略 |
| 数据尚未落盘 | 不推进内存游标 |
| 连接断开 | 从 durable cursor 重建 |
| 索引回绕缺少证据 | 失败关闭 |
如果游标在数据落盘前推进,App 又刚好退出,下一次连接会从更后面开始,那一页就永久丢了。
这部分写完以后,我对“补传完成”的理解变了:把多页数据读回来只是第一步,重试和崩溃以后本地记录仍然连续,才算真正完成。
AidexKit 负责 iOS 上的真实生命周期
AidexKit 分成四个 target:
| Target | 职责 |
|---|---|
AidexKitCore | crypto、frame、parser 和协议模型 |
AidexKit | CoreBluetooth、Keychain 和 CGMManager |
AidexKitUI | 添加、二维码扫描、设置和状态页面 |
AidexKitPlugin.loopplugin | Loop 插件入口 |
AidexKitCore 可以离开完整 App 单独测试。扫描、后台恢复和插件嵌入的问题,则留在平台层和工作区排查。
蓝牙这一层最麻烦的不是 API,而是所有权。
早期版本里,添加页面和正式 manager 可以创建不同的 central。真机运行后很快出现问题:旧扫描还没结束,新连接已经开始;系统恢复的 peripheral 又回到原来的 delegate;timeout 和 pending request 也没有唯一所有者。
现在每个 AidexCGMManager 只使用一个 CBCentralManager:
扫描或恢复
→ 连接
→ 发现服务和特征
→ 恢复凭据或首次配对
→ 建立 session
→ 读取状态
→ 补传历史
→ ready
页面只展示和发起动作,不拥有独立连接。断线时清理 session、pending request 和本轮历史缓存,但保留允许跨连接恢复的 pair key。
凭据没有进入 Loop 的 rawState
LoopKit 的 CGMManager 会保存 rawState。如果把重新连接需要的所有内容都放进去,pair key 可能跟着 Issue Report 或调试信息被导出。
AidexKit 的 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
}
真正的凭据进入 Keychain,并限制为当前设备首次解锁后可用:
attributes[kSecAttrAccessible as String] =
kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
普通“删除 CGM”也不会自动变成设备解绑。当前实现删除 manager 引用,但保留仍可恢复的 Keychain 凭据。真正改变设备绑定关系的操作,需要独立 UI、确认和失败恢复。
能解析,不等于能交给 Loop
实时 payload 解开以后,AidexKitCore 还会检查状态、历史状态、保留位和时间偏移:
public var passesConservativeSafetyGate: Bool {
glucoseIsValid
&& historyStatus == .normal
&& hasObservedNormalPackedMarker
&& status == 0
&& calibrationTemperature == 0
&& timeOffsetMinutes >= 60
}
未知状态、异常时间和重复索引不会生成一个“看起来正常”的 NewGlucoseSample,而是返回 .unreliableData 或直接忽略重复值。
目前已经能把七档趋势映射成 LoopKit GlucoseTrend,但物理速率的缩放还没有足够证据,所以 trendRate 仍然是 nil。
实时和历史还必须使用同一套身份:
credentialIdentifier + sensorStart + timeOffsetMinutes
这样同一分钟从历史补传和实时通知各到一次,Loop 仍能把它们识别成同一条记录。补传期间到达的实时通知会先缓存,等历史流程结束后再按索引发布,避免两条路径争抢游标。
接进 LoopWorkspace 后,才算完整跑过一次
AidexKit 最初通过 sibling project 接入,方便快速调试。真机流程走通后,我把它改成 Git submodule,让 LoopWorkspace 固定一个明确提交。
上层工作区只负责:
固定 AidexKit 版本
→ 加入 workspace 和共享 scheme
→ 构建四个 target
→ 复制 loopplugin
→ 从空 DerivedData 验证嵌入和签名
目前已经确认和仍待验证的范围如下:
| 已确认 | 仍待验证 |
|---|---|
| 扫描和二维码添加 | 更多 iOS 与固件组合 |
| 一次 iOS 首次业务配对 | 长时间后台和系统终止 |
| Keychain 保存和重启恢复 | 自然更换传感器 |
| 每分钟实时数据进入 Loop | 趋势速率物理单位 |
| 离线窗口后的历史补传 | BLE heartbeat |
| 实时与历史样本去重 | 长期影子模式表现 |
| 干净环境构建和签名 | 闭环使用前的完整验证 |
看到数值第一次进入 Loop 时当然很兴奋,但一次前台运行和普通重启,还不足以写成“已经稳定支持”。
和 AI Agent 协作时,停止点也很重要
AI Agent 很适合做静态检索、同步文档、迁移 parser 和补边界测试。但如果只说“继续完善协议”,它很容易把扩大范围当成进展。
我后来会在 Goal 里同时写清四件事:
| 内容 | 例子 |
|---|---|
| 事实来源 | reference、research、Python 参考 |
| 允许范围 | 静态分析、合成测试和离线回放 |
| 禁止范围 | 输出凭据、猜未知字段、自动操作设备 |
| 停止点 | core 完成后等待 FFI 选择 |
Rust core 完成以后,Agent 没有顺手再建 binding;离线材料分析到上限以后,也没有重复搜索同一批文件,再把证据不足包装成新结论。
以前我更关心 Agent 能不能持续工作。这次项目让我意识到,知道什么时候不继续,同样重要。
回头看这次接入
如果只看结果,这次工作是给 Loop 增加了一个新的 CGM 插件。
真正耗时间的,是判断一条结论现在走到了哪一步:只是能解释,还是已经能测试;只是能测试,还是可以进入平台驱动;只是前台跑通,还是足够向 Loop 声明一种能力。
三个仓库刚好接住了这几层:
microtech-protocol-analysis 保存证据和未知
AidexKit 处理连接、恢复和数据发布
LoopWorkspace 固定版本并验证完整 App
Rust core 没有被强行塞进 Swift,未知趋势速率没有填默认值,普通删除没有顺手变成设备解绑,前台通知正常也没有马上打开 heartbeat。
这些地方看起来都像少做了一步,但边界反而更清楚。
第一次看到数据出现,只能说明方向大概对了。断开连接、重启 App、补回缺口,再从一份干净的工作区重新构建,它仍然能回来,我才开始觉得这套实现真正站住了一点。