返回列表

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 和合成测试
AidexKitSwift 协议实现、CoreBluetooth、Keychain 和 LoopKit 插件
LoopWorkspace固定插件版本,完成构建、嵌入和真机验证

下面保留实际工程名和部分非敏感代码,但不会出现真实设备标识、密钥、血糖样本、私有远端地址和可能改变设备状态的命令。

从 BLE 协议分析、Python 与 Rust 验证到 Swift 和 Loop 集成的开发流程

协议结论要能追溯,也要能运行

最早只有 microtech-protocol-analysis。Android App 的静态分析、已有抓包、运行时行为和设备日志都放在这里。

研究初期,文档里经常出现“这个位置可能是时间索引”“这个状态看起来表示无效”。时间一长,很难再判断一条结论是真机确认、静态分析,还是为了让当前样本对上而做的推测。

后来我把资料分成三层:

docs/
  reference/       已确认、可以指导开发的结论
  research/        调查过程、证据等级和剩余未知
  planning/        下一阶段范围、风险和验收条件

字段也按证据处理:

证据状态进入代码后的做法
已确认转成稳定模型和业务含义
推断保留原始值和来源,不作产品承诺
未知使用 rawreserved 或失败关闭

例如某个 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职责
AidexKitCorecrypto、frame、parser 和协议模型
AidexKitCoreBluetooth、Keychain 和 CGMManager
AidexKitUI添加、二维码扫描、设置和状态页面
AidexKitPlugin.looppluginLoop 插件入口

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、补回缺口,再从一份干净的工作区重新构建,它仍然能回来,我才开始觉得这套实现真正站住了一点。