nglessner/o2ring-s-protocol

GitHub: nglessner/o2ring-s-protocol

针对 Wellue O2Ring-S (T8520) 脉搏血氧仪的 BLE 协议逆向文档与 Python 参考实现,使开发者能绕过厂商应用直接连接设备并采集血氧心率数据。

Stars: 3 | Forks: 1

# Wellue O2Ring-S (T8520) BLE 协议 一份逆向工程的参考文档,针对以下设备使用的蓝牙低功耗 (BLE) 协议: Wellue O2Ring-S 脉搏血氧仪(型号代码 T8520,也作为 "Checkme O2Ring-S" / "OxyLink" 销售)。端到端可工作功能包括:设备信息、时间 设置、实时 SpO2/HR 流传输、存储文件列表,以及文件下载—— 所有这些均无需绑定或通过供应商应用中转。 该协议与旧版 O2Ring (PO1/PO2/PO3 及 更早的 T 系列) 记录在 [farolone/wellue-o2ring-protocol](https://github.com/farolone/wellue-o2ring-protocol) 的协议**不同**。 旧版协议使用 GATT 服务 `14839ac4-...`;而 T8520 完全不 暴露该服务,而是实现了 Wellue 内部称之为 "OxyII" 的独立协议。目前所有针对 O2Ring 的开源工具 (`MackeyStingray/o2r`, `farolone/wellue-o2ring-protocol`, `ecostech/viatom-ble`) 都针对旧版服务,并在面对 T8520 时会静默失败。 本文档是对自 2025-10-16 起开放的 [MackeyStingray/o2r#5](https://github.com/MackeyStingray/o2r/issues/5) 的部分解答。 ## 状态 | 功能 | 状态 | |---|---| | 发现与连接 (无绑定) | 已验证 | | GET_INFO (序列号, 固件版本, 日期时间) | 已验证 | | GET_BATTERY | 已验证 | | SET_UTC_TIME | 已验证 (双向往返, 字节精确) | | GET_FILE_LIST | 已验证 | | READ_FILE (开始 / 数据 / 结束) | 已验证, 与 ViHealth 导出字节等效 | | 实时 SpO2 + HR 流 | 已验证 | | `cmd=0xFF` 认证推导 | 已验证 (算法从零复现) | | `cmd=0x00`, `cmd=0x10` 设置步骤 | 仅发送并确认, 具体用途未知 | | GET_CONFIG / SET_CONFIG | 已在供应商 SDK 中记录; 尚未实际测试 | | 实时波形 / PPG | 已记录; 尚未实际测试 | | 恢复出厂设置 / OTA | 已记录; 尚未实际测试 | 通过 BLE 拉取的文件与供应商 应用的 USB 导出之间的端到端字节等效性,已通过两次真实录音 (763 B 和 2647 B) 的 SHA-256 散列进行了验证;在另一次录音的 22,541 个采样中,两种格式完全匹配,零采样不一致。 ## 识别设备 T8520 根据状态以两种不同的模式进行广播: - **录制模式** (戴在手指上, 正在录制): 作为公共风格地址广播,本地名称为 `T8520_` (例如 `T8520_e85a`)。制造商 ID 为 `0x036F` (Viatom)。在此模式下暴露的 GATT 布局是精简的——OxyII 服务无法被可靠发现。 - **OxyII / 同步模式** (空闲, 或录制完成后的短时间内): 作为随机静态地址广播,本地名称为 `S8-AW ` ,制造商 ID 为 `0xF34E`。这是暴露完整 OxyII 服务并支持文件传输的模式。 随机静态地址在每次恢复出厂设置时都会轮换,因此任何客户端 都必须通过服务 UUID、制造商 ID 或名称前缀进行扫描和匹配—— 不要硬编码 MAC 地址。 用户无需为 OxyII 模式“触发定稿”或进行任何特殊操作。只要设备处于唤醒状态,指环就会暴露该模式——佩戴它或按下按钮就足够了。 ## BLE 服务与特征 OxyII 服务: | 角色 | UUID | |---|---| | 服务 | `E8FB0001-A14B-98F9-831B-4E2941D01248` | | 写入 (无响应写入) | `E8FB0002-A14B-98F9-831B-4E2941D01248` | | 通知 | `E8FB0003-A14B-98F9-831B-4E2941D01248` | 连接要求: - **仅限 LE 链接。** 不需要 SMP 配对或绑定。 - **`own_address_type = PUBLIC`** 适用于现代控制器 (已验证 Intel BT 5.4)。测试中也接受随机的自身地址。 - **必须在文件传输之前协商 ATT MTU = 517。** 这是整个协议中最不明显的陷阱——见下文。 - 必须向通知特征写入 `0x0100` 的 CCCD (仅通知)。某些 BLE 协议栈默认为 `0x0001`-LE,这会被 指环的状态机静默拒绝。 ### MTU 陷阱 `READ_FILE_DATA` (cmd=0xF3) 的回复是 512 字节的数据块。如果中心设备 尚未协商足够大的 ATT MTU 以在一个 PDU 中容纳数据块, 指环会在它们产生回复**之前静默丢弃 `READ_FILE_START` (cmd=0xF2) 请求**。协议中的其他每个命令的回复都 ≤60 字节,并且在默认的 MTU=23 下运行良好,这掩盖了 问题并产生了误导性的症状“除文件传输外一切正常”。 修复方法:在连接后,进行任何 GATT 发现之前,立即 发出 ATT MTU 交换请求 517。供应商应用请求 517 / 接受 247;两者均足够。 在 Bumble 中: ``` peer = Peer(connection) await peer.request_mtu(517) ``` 某些 BLE 协议栈 (尤其是 Bumble) **不**自动协商 MTU;其他协议栈 (尤其是 macOS / iOS 上的 Bleak) 会自动协商。无论您使用哪种协议栈,都请使用 btmon 或 Wireshark 验证 LE 连接建立后不久,线路上是否存在 `ATT Exchange MTU Request` 数据包。如果没有, 文件传输命令将静默失败。 #### 经验证实与固件变体细节 在固件 `2D010002` 上通过一项对照测试确认了“静默丢弃”机制, 该测试在没有发出 `ATT Exchange MTU Request` 的情况下连接到指环。当 MTU 保持在默认的 23 时: - `cmd=0x10`、`cmd=0xC0`、`cmd=0x00` 和 `cmd=0xF1` 都返回了完整的 回复——包括 48 字节的 `cmd=0x00` 指纹帧和包含四个文件的 73 字节 `cmd=0xF1 GET_FILE_LIST` 帧。因此, 大于 20 字节 ATT 上限的通知由 BlueZ/Bumble 在 ATT 层之上透明地重新组装;“MTU=23 截断 大回复”**不是**限制机制。 - `cmd=0xF2 READ_FILE_START` 在四秒的时间窗口内对四个文件产生了绝对的零字节—— 不是部分缓冲区,也不是截断的回复。指环保持沉默。无论 Wellue 的固件 在 F2 入口点进行什么检查,它都依赖于链路/ATT 层上与 MTU 或 DLE 相关的某些东西,这些条件满足 517 字节交换但不满足 23 字节交换。F2 *自身* 的回复小到 可以适应任何一种情况——该门控似乎预见了随后将出现的 512 字节 F3 数据块。 一份关于固件 `2D010003` 的现场报告 ([issue #1](https://github.com/nglessner/o2ring-s-protocol/issues/1)) 描述了不同的门控机制:在那里 `cmd=0xF2` 在 MTU=23 下成功,并且 `cmd=0xF3` 数据块通过 BlueZ 很好地重新组装;该固件上的限制 表现为每次 BLE 连接的 F3 吞吐量限制 (预算在传输中途耗尽;重新连接并恢复可完成文件)。这两个观察结果并不一定矛盾——F2 门控可能在 `2D010002` 和 `2D010003` 之间被放宽了,而单独的每次连接预算始终存在,并且只有在 F2 不再是阻碍时才可见。 实际上,连接后立即请求 MTU=517 是 目前在测试过的每个固件上都普遍可行的路径。 ### 通知帧 当 MTU 为 517 时,在该协议中观察到的每个回复都适合单个 ATT 句柄值通知 PDU——包括 512 字节的 `READ_FILE_DATA` 块。一个简单的“将每个通知解码为一帧”循环即可工作。如果您协商的 MTU 小于 517,您将需要在解码之前重新组装多 PDU 回复。 ## 帧格式 每个请求和响应都使用相同的信封: ``` +------+-----+------+------+-----+--------+--------+----------+-----+ | 0xA5 | cmd | ~cmd | flag | seq | len_lo | len_hi | payload | crc | +------+-----+------+------+-----+--------+--------+----------+-----+ 1 1 1 1 1 1 1 len bytes 1 ``` | 字段 | 大小 | 描述 | |---|---|---| | 引导字节 | 1 字节 | 始终为 `0xA5`。 | | `cmd` | 1 字节 | 操作码。 | | `~cmd` | 1 字节 | `cmd` 的按位取反。设备会验证此项。 | | `flag` | 1 字节 | `0x00` 表示 app→设备 请求;`0x01` 表示 设备→app 响应。 | | `seq` | 1 字节 | 主机为每个请求设置的计数器。设备在其回复中回显该值,但不强制单调性——观察到的流量在请求之间重用值 (例如,`cmd=0xFF` 和 `cmd=0x10` 的 `seq=0`,`cmd=0xC0` 和 `cmd=0x00` 的 `seq=1`)。重新实现可以为每个请求递增,也可以将其设置为常量;两者都可行。 | | `len` | 2 字节 | 小端序有效载荷长度 (不包括头部和 CRC)。 | | `payload` | `len` 字节 | 特定于命令。可以是明文或 AES 加密 (见下文)。 | | `crc` | 1 字节 | 覆盖整个帧的 CRC-8,*包括* `0xA5` 引导字节并*仅排除*尾随的 CRC 字节本身。 | 头部为 7 字节,总帧开销为 8 字节。 ### CRC-8 多项式 `0x07`,初始值 `0x00`,无输入/输出反射,无 XOR-out。 与标准 CRC-8 / "ITU" CRC-8 相同。 ``` def crc8(data: bytes) -> int: crc = 0 for b in data: crc ^= b for _ in range(8): crc = ((crc << 1) ^ 0x07) if (crc & 0x80) else (crc << 1) crc &= 0xFF return crc ``` 一个常见错误 (本文作者也犯过) 是使用 XOR——这 与旧版 O2Ring 的校验和匹配,而不是 OxyII。两者完全 不同。请根据这个 5 字节的测试用例验证您的 CRC: | 字节 | CRC | |---|---| | `A5 E1 1E 00 02 00 00` (GET_INFO 请求, 无有效载荷, seq=2) | `BF` | ## 加密 加密是**按命令**进行的,而不是“认证后的全会话范围”。每个 命令都以明文发送,或者其有效载荷使用 AES-128-ECB-PKCS7 加密。帧信封 (头部 + CRC) 是根据 最终在传输线路上的有效载荷字节 (明文或密文) 计算得出的。 实际上,此协议中的几乎所有命令都是以**明文**发送的。 在供应商流量中观察到的唯一例外是 `cmd=0xFF`,这是一个 单向认证/握手消息,使用 XOR 方案而不是 AES。 GET_CONFIG / SET_CONFIG 以及其他一些管理命令可能使用 带有派生会话密钥的 AES;这尚未进行端到端测试。 ### `cmd=0xFF` 认证 `cmd=0xFF` 是一条单向消息 (从未观察到回复),在连接后 立即发送,以将指环的状态机置于接受文件传输命令的模式。16 字节的有效载荷构造 如下: ``` LEPUCLOUD_MD5 = MD5("lepucloud") # 16-byte constant session_key = derive_session_key(serial_prefix, ts) auth_payload = bytewise XOR(session_key, LEPUCLOUD_MD5) ``` 其中 `derive_session_key` 为: ``` def derive_session_key(serial_prefix: str, ts: int) -> bytes: """16 bytes: [0..7] = MD5("lepucloud") at even indices [0,2,4,6,8,10,12,14] [8..11] = first 4 ASCII bytes of `serial_prefix` [12..15] = (ts >> 0), (ts >> 1), (ts >> 2), (ts >> 3) """ md5 = hashlib.md5(b"lepucloud").digest() key = bytearray(16) for i in range(8): key[i] = md5[i * 2] key[8:12] = serial_prefix[:4].encode("ascii") for n in range(4): key[12 + n] = (ts >> n) & 0xFF return bytes(key) ``` `serial_prefix` 是一个 4 字节的 ASCII 字符串。推荐的可移植 默认值是文字字符串 `"0000"`,设备接受它 而不需要事先的 GET_INFO。供应商应用有时会替换 设备实际序列号的前 4 个字符 (可以从 先前的未加密 GET_INFO 调用中获取);两种形式都可行。 `ts` 是当前的 Unix 纪元时间 (以秒为单位)。 奇特的 `>> 0, 1, 2, 3` 模式 (而不是通常的 `>> 0, 8, 16, 24` 字节提取) 是对供应商实现的忠实移植。 无论这是他们代码中的错误还是故意的弱时间耦合 方案,都不得而知;无论哪种情况,双方都将以相同的方式计算它, 指环接受它,因此重新实现应该匹配。 该帧作为 `cmd=0xFF`,明文信,16 字节 XOR 有效载荷发送, 没有回复。然后指环处于接受 cmd=0xF1 / cmd=0xF2 / cmd=0xF3 / cmd=0xF4 的状态。 ### AES 辅助函数 (用于确实使用它的命令) ``` from Crypto.Cipher import AES from Crypto.Util.Padding import pad, unpad def aes_encrypt(plaintext: bytes, key: bytes) -> bytes: return AES.new(key, AES.MODE_ECB).encrypt(pad(plaintext, 16)) def aes_decrypt(ciphertext: bytes, key: bytes) -> bytes: return unpad(AES.new(key, AES.MODE_ECB).decrypt(ciphertext), 16) ``` 密钥始终为 16 字节。PKCS7 填充。ECB 模式 (无 IV)。 ## 命令参考 | 操作码 | 名称 | 有效载荷 | 回复 | 备注 | |---|---|---|---|---| | `0x00` | (设置) | 空 | 40 字节明文 | 每设备指纹常数;在给定设备上的不同会话之间字节相同。 | | `0x03` | LIVE_SAMPLES_A | 空 | 6 字节头部 + ≤250 个采样 | 实时 SpO2/HR 流,约 1 个采样/秒。 | | `0x04` | LIVE_SAMPLES_B | 空 | 24 字节头部 + 2 字节计数 + 采样 | 与 0x03 数据相同;供应商应用使用此命令。在文件传输流程中,不要在 `cmd=0xF2` 之前调用——这似乎会使指环进入“实时流传输”状态,从而阻止文件命令。 | | `0x05` | (历史?) | 空 | 922 字节 | u8 计数 + 102 × 9 字节记录,每条以 `03 00 ...` 开头。动态数据 (每次调用都会改变)。用途未知。 | | `0x10` | (设置) | 1 字节 `0x00` | 0 字节确认 | 认证后握手所必需。用途未知。 | | `0xC0` | SET_UTC_TIME | 8 字节 (见下文) | 确认 | 设置指环的 RTC。 | | `0xE1` | GET_INFO | 空 | 60 字节明文 | 序列号、固件版本、日期时间等。 | | `0xE4` | GET_BATTERY | 空 | 4 字节 | 电池电量 + 状态。 | | `0xF1` | GET_FILE_LIST | 空 | u8 计数 + N × 16 字节 | 每个槽位:14 字节 ASCII 时间戳 + 2 字节零填充。 | | `0xF2` | READ_FILE_START | 20 字节 (见下文) | 4 字节文件大小 + 元数据 | 打开文件进行读取。要求 MTU ≥ 517。 | | `0xF3` | READ_FILE_DATA | 4 字节 LE 偏移量 | 最大 512 字节数据块 | 循环直到回复为空或 `offset + len >= file_size`。 | | `0xF4` | READ_FILE_END | 空 | 确认 | 关闭当前文件。 | | `0xFF` | AUTH | 16 字节 XOR 有效载荷 | 无 | 文件传输前必需。见加密部分。 | 供应商 SDK 暴露了尚未进行端到端测试的额外命令: | 操作码 (根据 SDK) | 名称 | 备注 | |---|---|---| | (尚未捕获) | GET_CONFIG | 返回指环设置:亮度模式、蜂鸣器、显示模式、HR 报警阈值、马达 (振动)、SpO2 报警阈值、录制采样间隔。 | | (尚未捕获) | SET_CONFIG | 相同字段,写入端。 | | (尚未捕获) | GET_RT_PARAM | 实时参数 (单次)。 | | (尚未捕获) | GET_RT_WAVE | 实时波形流。 | | (尚未捕获) | GET_RT_PPG | 实时 PPG (原始光电容积脉搏波)。 | | (尚未捕获) | RESET | 软重启。 | | (尚未捕获) | FACTORY_RESET_ALL | 清除配对 + 录制记录。 | 这些名称对应于供应商应用暴露的功能。它们的 操作码字节和有效载荷布局需要从供应商应用执行相应功能时的 HCI 嗅探中捕获; 此项工作尚未在此完成。 ### `GET_INFO` (cmd=0xE1) 回复布局 60 字节明文有效载荷: | 偏移量 | 大小 | 字段 | |---|---|---| | 0–1 | 2 | u16 大小/计数标记 (在观察到的固件上为 `0x0042`) | | 2–3 | 2 | u16 协议版本 | | 4–7 | 4 | 标志 / 类型位 | | 8 | 1 | 分隔符 (`0x00`) | | 9–16 | 8 | 固件版本,ASCII (例如 `"2D010002"`) | | 17 | 1 | 分隔符 (`0x01`) | | 18–19 | 2 | u16 LE — 电池 / 容量 | | 20–21 | 2 | u16 LE — 存储 / 总采样数 | | 22–23 | 2 | 标志 | | 24–31 | 8 | 日期时间:年-LE (2 字节), 月, 日, 时, 分, 秒, 字节-7 (用途不明;可安全忽略) | | 32–35 | 4 | 构建 / 型号代码 | | 36 | 1 | 保留 | | 37 | 1 | u8 序列号长度 (通常为 `0x0A` = 10) | | 38…37+sn_len | sn_len | 序列号 ASCII (例如 `"25B2303210"`) | | 剩余部分 | … | 零填充 | 重新实现应将此处未列出的任何字段视为不透明的, 并且不依赖它们。某些范围可能在本文作者 未测试过的固件变体上携带数据。 ### `SET_UTC_TIME` (cmd=0xC0) 有效载荷 8 字节: | 偏移量 | 大小 | 字段 | |---|---|---| | 0–1 | 2 | u16 LE — 年 | | 2 | 1 | 月 (1–12) | | 3 | 1 | 日 (1–31) | | 4 | 1 | 时 (0–23) | | 5 | 1 | 分 (0–59) | | 6 | 1 | 秒 (0–59) | | 7 | 1 | 未知 — 供应商应用发送 `0xCE`;发送 `0x00` 也被接受,无可见副作用 | 经验观察 (通过协议自身在 GET_INFO 中的 datetime 字段以及指环显示屏上的视觉对比,对指环的 RTC 进行设置/读取/差异比较):指环**原样**存储时间字段。没有 内部时区转换。您发送的任何挂钟时间值就是 指环显示屏读回的值,也是下一次录制的文件名将包含的内容 (`YYYYMMDDhhmmss`),以及后续 GET_INFO 调用将返回的值。 如果您想要对机器友好的文件名,请发送 UTC。如果您想要一个 显示屏可读的时钟,请发送本地时间。指环本身并不关心 您使用哪一个。 ### `GET_FILE_LIST` (cmd=0xF1) 回复 ``` [0] u8 file count [1..] N × 16-byte slots Each slot: bytes 0..13 ASCII timestamp YYYYMMDDhhmmss bytes 14..15 zero pad ``` 时间戳是录制开始时间,采用指环在录制时 设置的任何挂钟时区——与 SET_UTC_TIME 的约定相同。 此回复中**没有**文件大小。它由 READ_FILE_START 报告。 ### `READ_FILE_START` (cmd=0xF2) 有效载荷 20 字节: | 偏移量 | 大小 | 字段 | |---|---|---| | 0–15 | 16 | 文件名槽位。由 `GET_FILE_LIST` 返回的 14 字节 ASCII 时间戳 (例如 `20260427105949`) 占据字节 0–13;字节 14–15 为零填充。 | | 16–19 | 4 | u32 LE — 文件类型 (在观察到的流量中仅设置了低字节) | 文件类型值 (来自供应商 SDK): | 值 | 名称 | 描述 | |---|---|---| | 0 | OXY | 血氧 (SpO2 + HR + 运动) — 主要的睡眠记录 | | 1 | PPG | 原始光电容积脉搏波 | | 2 | (保留) | 在 SDK 常量中观察到;用途未知 | 回复的前 4 个字节是 u32 LE 文件大小。剩余字节是 元数据 (待定;似乎包括采样计数和状态标志)——对于 直接拉取文件,仅需要大小。 ### `READ_FILE_DATA` (cmd=0xF3) 循环 发送从 `0` 开始的 4 字节 LE 偏移量。指环回复最多 512 字节 (最后一块会更少)。将您的偏移量增加接收到的 字节数;继续直到回复为空或您的偏移量达到 READ_FILE_START 公布的文件大小。 ``` collected = bytearray() offset = 0 while offset < file_size: chunk = await read_file_data(offset) if not chunk: break collected.extend(chunk) offset += len(chunk) ``` ### `READ_FILE_END` (cmd=0xF4) 空有效载荷。指环确认。在不同文件上执行后续 READ_FILE_START 之前必须执行此操作——如果没有它,第二次打开将被 静默拒绝。 ## 工作会话序列 这是作者在固件为 `2D010002` 的 T8520 上端到端验证过的 MTU 交换后流程: ``` 1. ATT MTU exchange (517) 2. Service discovery 3. CCCD write 0x0100 on notify characteristic 4. cmd=0xFF (auth, 16-byte XOR payload, seq=0) no reply 5. cmd=0x10 (1-byte 0x00, seq=0) 0-byte ack 6. cmd=0xC0 SET_UTC_TIME (8 bytes, seq=1) ack 7. cmd=0x00 (empty, seq=1) 40-byte fingerprint 8. cmd=0xF1 GET_FILE_LIST (empty, seq=2) count + N × 16-byte slots 9. For each file: cmd=0xF2 READ_FILE_START (20 bytes, seq=N) file size + metadata loop: cmd=0xF3 READ_FILE_DATA (4-byte offset, seq=N+1) ≤512-byte chunk until offset >= file_size cmd=0xF4 READ_FILE_END (empty, seq=M) ack ``` 在 `cmd=0xF2` 之前调用 `cmd=0x04` (实时采样) 会使指环进入 实时流传输状态,该状态会阻止文件命令,直到断开连接。 在给定的会话中,要么进行实时采样,**要么**进行文件传输,不能 同时进行。 `cmd=0xE1` GET_INFO 可以在流程中的任何点发出,而不会 破坏状态。 应该在进入 F3 读取循环之前消费掉 `cmd=0x00` 的 40 字节明文回复。并发发送 `cmd=0xF2` 和 `cmd=0x00` 然后循环 `cmd=0xF3` 的实现已被观察到 会将 40 字节的回复误认为是 `block_len=40` 的 F3 数据块。 底层传输仍然会完成 (回复落在文件偏移量 0 处,并被从偏移量 40 开始的 F3 数据块覆盖),但这是一个 值得避免的令人困惑的边缘情况,可以通过将认证后握手严格 安排在文件传输循环之前来避免。 ## 存储文件格式 在该设备系列中可见两种 SpO2 录制格式: ### 格式 A: v1.x (最常见, 此设备产生的格式) 10 字节头部,后跟 3 字节的采样记录,1 个采样/秒。 ``` Header (10 bytes): 01 03 00 00 00 00 00 00 04 00 Body (3 bytes per record): byte 0 spo2 (percent, 0–100, 0 = invalid) byte 1 heart rate (bpm, 0 = invalid) byte 2 status flags (low bits = invalid/motion/etc; nonzero = sample should be treated as suspect) ``` 头部偏移量 8–9 处的 `04 00` 似乎是以某种单位表示的采样 间隔 (可能是十分之一秒),但尚未观察到 `04 00` 以外的其他值。 #### 尾部 (48 字节) 每个定稿的格式 A 文件都以 48 字节的会话统计尾部结束, 供应商应用将其用于会话总结 PDF。字段偏移量相对于 尾部开始处 (`file_size − 48`)。 | 偏移量 | 大小 | 字段 | |---|---|---| | 0–3 | 4 | 不透明的每次录制字节 (跨文件可变;字节 3 始终为 `0x00`;可能是散列或每次录制的 id) | | 4–7 | 4 | 子魔术字 `48 12 5a da` | | 8–9 | 2 | 不透明的每次录制字节 (可变) | | 10–11 | 2 | u16 LE 计数器 — 偶尔递增;**不**严格属于每次录制 (见下文) | | 12–13 | 2 | u16 LE — 总采样数 = 录制的总秒数 | | 14–15 | 2 | 保留 (始终为 `0x00 0x00`;与字节 12–15 构成 u32 采样计数一致) | | 16–18 | 3 | 格式版本戳 `01 01 03` | | 19–33 | 15 | 保留 (零) | | 34 | 1 | 平均 SpO₂ (四舍五入整数) | | 35 | 1 | 最低 SpO₂ (与体格最低值字节精确匹配) | | 36 | 1 | ≥ 3% 的血氧饱和度下降次数 | | 37 | 1 | ≥ 4% 的血氧饱和度下降次数 | | 38 | 1 | 保留 (零) | | 39–40 | 2 | u16 LE — SpO₂ < 90% 的总秒数 | | 41 | 1 | 不同的 < 90% 血氧下降发作次数 | | 42 | 1 | O₂ 分数 × 10 (`0` = N/A;例如在短时间会话中) | | 43–46 | 4 | 保留 (零) | | 47 | 1 | 平均心率 (四舍五入整数) | 此映射由 `@knifebunny` 在 [issue #1](https://github.com/nglessner/o2ring-s-protocol/issues/1) (固件 `2D010003`) 中贡献,并由 Ilya 在 27 次供应商应用 PDF 导出中进行了交叉验证工作。在本文作者的 `2D010002` 指环上的八个单独录制中独立验证:子魔术字偏移量、 总采样数、格式戳、最低 SpO₂、下降次数和 N/A 分数 x10 均为字节精确;平均 SpO₂ 和平均心率与主体衍生的平均值在 ±1 范围内一致。 **关于偏移量 10 处的计数器。** 最初的报告将其描述为“每次录制单调递增”。在本文使用的验证捕获中,它 在跨越 22 小时的八次录制中仅精确递增了一次,在同一天进行的七次连续录制中保持平稳。最可能的解释是每次开机或每次唤醒的计数器,而不是录制 ID。请将其用作非递减的聚类提示,而不是唯一的录制键。 **锚点作为定稿判定条件。** 指环有时会 在尾部刷新*之前*通过 `cmd=0xF2` 报告文件的完整字节 计数。仅靠大小相等并不是可靠的“此文件已完成”检查——在 `file_size − 44` 处存在 `48 12 5a da` 子魔术字才是。达到完整大小但没有锚点的 文件应在随后的同步周期中重新拉取。 ### 格式 B: v3 (.vld) 存在于较旧的固件和其他 Wellue/Viatom 脉搏血氧仪中。40 字节的 头部包含结构化的日期时间/持续时间,然后是以每 4 秒 1 个采样的速率生成 5 字节的记录 `[spo2, hr, invalid, motion, vibration]`。 在 T8520 的 BLE READ_FILE 流程中,目前仅观察到生成格式 A。如果您在 T8520 中看到格式 B,请在本仓库开一个 issue。 ## 参考实现 [`oxyii_protocol.py`](./oxyii_protocol.py) 是一个纯函数参考实现: 帧编解码器、CRC、AES 辅助函数、`derive_session_key`、操作码常量, 以及 GET_INFO 和 GET_FILE_LIST 的解析器。无 I/O,无 BLE 库 依赖——直接放入项目中,在顶层使用您选择的 BLE 库即可。在 Python 3.10+ 下测试,AES 依赖 `pycryptodome`。 [`example_pull.py`](./example_pull.py) 是一个最小化的端到端示例, 使用 [Bumble](https://github.com/google/bumble) 从指环中拉取所有存储的 录制记录。包含 BLE 连接管道代码在内大约 300 行。 ## 待解决问题 观察到少量字段,但其含义尚未得到验证。 在此列出,以便重新实现者可以将它们视为不透明的,而不是 去猜测: - **`cmd=0x10` 和 `cmd=0x00` 的语义。** 两者在认证后握手中都是必需的——跳过它们会导致 `cmd=0xF2` 被静默拒绝——但它们的有效载荷没有携带明显的信息。`cmd=0x00` 的 40 字节回复在给定设备上的不同会话之间字节相同,这与固定的设备指纹而不是会话状态一致。 - **`SET_UTC_TIME` 的字节 7。** `0xCE` (供应商应用发送的内容) 和 `0x00` 都被接受,在显示、文件名格式或 RTC 行为方面没有可观察到的差异。视为未使用。 - **`GET_INFO` 偏移量 4–7, 22–23, 32–35。** 可能是型号代码、标志位和容量描述符,但在本文作者可用的捕获中这些值没有变化。请将它们保留在 `raw` 字段中,并且只解析您需要的内容。 ## 参考资料 - [`farolone/wellue-o2ring-protocol`](https://github.com/farolone/wellue-o2ring-protocol) — 旧版 O2Ring 的协议文档 (不同协议;有用的 上下文)。 - [`MackeyStingray/o2r`](https://github.com/MackeyStingray/o2r) — 旧版 O2Ring CLI;issue #5 是本文档响应的问题来源。 - Bluetooth SIG 公司标识符 — `0x036F` (Viatom,用于录制模式广播),`0xF34E` (用于 OxyII 模式广播; 在 SIG 数据库中未分配,推测为供应商内部使用)。 ## 贡献 欢迎提交 Issue 和 PR。特别感兴趣的是: - 本文未涵盖的固件变体的捕获记录。 - 供应商应用中 GET_CONFIG / SET_CONFIG / RT_WAVE 流程的 HCI 嗅探,以填补未验证的操作码。 - 在作者固件为 `2D010002` 的 T8520 之外的设备上的确认 (或反驳)。 在开启带有嗅探记录的 issue 时,请隐去您的序列号 以及 OxyII 随机静态地址中可能唯一标识您的硬件的任何部分。
标签:BLE, Checkme, GATT, IoT, O2Ring-S, OxyII协议, Python, SpO2, T8520, ViHealth, 个人医疗健康, 云资产清单, 健康监测, 医疗设备, 可穿戴技术, 心率, 数据提取, 无后门, 无线通信, 智能穿戴设备, 物联网, 硬件交互, 蓝牙低能耗, 蓝牙协议, 血氧仪, 血氧饱和度, 逆向工具, 逆向工程