vivo / iQOO 的 TWS 耳机在 Mac 上有个尴尬处境:耳机能连、能出声,但你想看电量、切降噪、管多连接设备,官方那套东西在 Mac 上根本用不了。VivoPods 就是为了补上这一块——一个原生 macOS 菜单栏应用,直接通过蓝牙跟耳机对话。
这篇文章分两部分。前半讲这个软件本身:它解决什么问题、为什么官方方案会卡住。后半讲支撑它的 VivoTWS 协议——耳机用的是 GAIA 帧,同一套命令跑在两条物理通道上,但帧封装并不一样,这是整个移植过程里最关键的一个发现。
先看软件长什么样
VivoPods 是纯状态栏应用,没有 Dock 图标,也没有主窗口。菜单栏上就是一个耳机图标加左右电量,点开是一个面板。

几个交互细节是按日常使用磨出来的:
只有一只耳机有读数时就只显示那一侧,两只都没读数就退回显示充电盒。
正在充电的部件带 ⚡ 并转绿,电量低于 20% 转红。
耳机没连时菜单栏图标自动隐藏,连上后自动出现。蓝牙关闭或没授权时显示 ⚠️,这样你仍然能打开面板退出或改设置。
支持开机自启,走
SMAppService,也能在 系统设置 ▸ 通用 ▸ 登录项与扩展 里管。
功能不多,但都是真机验证过的:菜单栏电量与充电状态、一键切降噪三档、多连接设备列表与切换断开,外加一个能收发原始帧的调试台。实测机型是 vivo TWS 5,内部型号 id 192(DPD2523_BASE),固件 2.11.1。
为什么官方 App 连不上
vivo 官方的「vivo耳机」App 可以以 iOS-on-Mac 方式装在 Apple Silicon 的 Mac 上,装是装得上,但在 Mac 上就是连不上耳机。
一开始很容易以为是蓝牙链路的问题。排查下来结论正好相反:链路完全正常——BLE GATT 已连接、可读可写、双向收发数据都通。真正卡住的是这一行日志,每秒重复一次,直到超时:
Connecting-A2DPState:未连接 bleState:5 -- 地址: AA:BB:CC:11:22:33
问题出在 App 判断「连上了没有」的方式。它用 iOS 的 AVAudioSession 来判断 A2DP 是否连接——VTWS.framework 里引用了 _AVAudioSessionPortBluetoothA2DP 和 _AVAudioSessionPortBluetoothHFP,并按 iOS 的端口 UID 格式 AA-BB-CC-11-22-33:output_Bluetooth 去比对。这套逻辑在 iOS 上没问题,但 macOS 的 AVAudioSession 兼容层不暴露蓝牙 A2DP/HFP 端口类型,于是这个判定实测 62/62 次全部失败,App 永远进不到「已连接」状态。
这一步的关键是:这不是设置能绕过的问题。唯一可能的用户侧变量——把耳机设为活动音频设备——已经满足了,它同时是默认输入和默认输出设备,App 照样看不见。
那能不能改包修一下?不行。这个 App 是 FairPlay 加密的正版包,主程序 cryptid 1,签名方是 Apple iPhone OS Application Signing,一改包就无法启动,没法本地修复。链路是通的,判定是死的,包又改不动——绕不过去,只能自己重写一个。
下面这张图把两条路径摆在一起看:左边是官方 App 卡死的地方,右边是 VivoPods 绕开判定后的走法。

思路其实很简单:既然链路本来就通,那就别去碰那个 A2DP 判定,直接在 BLE GATT 上收发耳机的控制协议就行。剩下的工作,就全落在「把这套协议吃透」上了。
VivoTWS 协议:一套命令,两条通道
先说底座。vivo TWS 用的是 GAIA 帧,vendor 号 0x001B。GAIA 是一套通用的耳机控制帧格式,vivo 在上面定义了自己的命令空间。这部分最难啃的骨头——帧结构、命令号、电量编码——上游的 HyperEars 和 TWS-Pods-PC 已经逆向整理得逐字节清楚,VivoPods 的协议层几乎是照着直译过来的。
但真机跑起来时,撞上了一个 README 里没写的差异,这也是整个移植过程中最关键的发现:同一套命令,在两条物理通道上的帧封装是不一样的。
两种 wire format
vivo 官方在 Android 和 iOS 上走的通道不同。Android 允许应用直接开 RFCOMM,走的是 SPP 通道,用的是完整 GAIA 帧;iOS 不允许非 MFi 应用用 SPP,只能走 BLE GATT,而 GATT 上用的是裸 content——没有 FF 帧头,也没有 version / flags / length 字段。
GATT 侧之所以不需要帧头,是因为 GATT 本身就提供了消息边界和长度,帧头那几个字段是多余的。

这个结论不是猜的,是由两条完整抓包短帧确定的——长度和字段完全自洽,不存在被截断的可能:
001b850900 (5 字节) → vendor 001B, cmd 0x8509, payload 00
001b0509141a08060b0c1d (11 字节) → cmd 0x0509, payload 14 1a 08 06 0b 0c 1d
= 20,26,8,6,11,12,29 → 2026-08-06 11:12:29
还有一条反向印证:官方 Android APK 里出现了私有 SPP UUID 00000837-…,但完全没有 00001100-… 这个 GATT 服务。两端各走一条通道,共用同一套命令空间,这就对上了。
VivoPods 当前实现的是 .gatt 通道(已真机验证),.classic 的编解码也写完了并有测试覆盖,只差传输层。
一个很坑的细节:写特征的 properties
BLE GATT 侧的读写特征是这几个:
服务 00001100-D102-11E1-9B23-00025B00A5A5
写 00001101-… properties = 0x04
通知 00001102-… properties = 0x12
其他 00001103-… properties = 0x16(官方 App 判为非目标,忽略)
写特征的 properties = 0x04 是个陷阱。它不是「写(带响应)」——CoreBluetooth 的位定义里 0x04 = writeWithoutResponse,0x08 才是 write。如果用 .withResponse 去发,会一律得到 Writing is not permitted.,而表现出来的现象是「能收不能发」,非常容易误判成权限问题或者 UUID 写错了。这个坑卡了一轮才定位到。
命令空间
响应命令等于请求命令 | 0x8000,也就是把最高位置 1。比如电量查询是 0x0207,耳机的上报就是 0x8207。
电量:8207
payload 是 status L R case flags。flags 的 bit0/1/2 分别表示左 / 右 / 仓正在充电。百分比大于 100 视为无效——耳机不在盒中时 case 常常是 0xFF。
00 55 5E FF 00 → 左 85% 右 94% 仓 无效 均未充电
case 字节这个 0xFF 值得记一笔:解析时用 0 <= v <= 100 的范围判断,正好把它过滤掉,不会误显示成「仓电量 255%」。
降噪:8230 / 8130
payload 是 status mode [noiseEffect] [transparencyEffect],其中 mode 三个值:0 降噪、1 关闭、2 通透。查询回 8230,设置发 0130、耳机回 8130 确认,回读确认模式确实变了。
多连接设备:8249
这块是官方 README 没涉及的部分,做了完整验证。vivo TWS 支持同时连多台设备并切换,上报的 payload 结构是这样:
[status:1][count:1] 然后每台设备重复:
[MAC:6][未知:3][状态:1][名称长度:1][UTF-8 名称]

下面是一个实测样本,len=87,3 台设备。MAC 和设备名都做了脱敏,但保持了原始 UTF-8 字节数,所以长度算术仍然可以自行验证:2 + 34 + 27 + 24 = 87。
00 03
AA BB CC 44 55 66 | 38 01 0C | 02 | 17 | "张小明的MacBook Air" (12+11=23=0x17)
DD EE FF 77 88 99 | 5A 02 0C | 00 | 10 | "小明的iQOO 13" (9+7=16=0x10)
11 22 33 AA BB CC | 5A 41 1C | 00 | 0D | "iQOO Pad2 Pro" (13=0x0D)
状态字节有三态,是通过下发 0x014A 观察列表变化实测出来的,不是单样本猜的:
0x00未连接0x01已连接,但不是当前活动设备0x02已连接,且为当前活动设备
MAC 后面那 3 字节的语义目前还没弄明白,原样保留。
设置多连接:014A(不是 0149)
这里有个反直觉的点。查询是 0x0249,但设置的对偶不是 0x0149。先试过 0x0149,带全零 MAC、空载荷、以及邻近的 0x024A 三种形态,全都没有任何回包,连错误码都没有——查询和设置在这套协议里并不对称。
正确的命令号 0x014A 是从官方 Android APK 反编译得到的。这里顺带记一下取命令号的方法,因为抓包只能看到「有什么」,看不到「还有什么没被触发」,反编译能补上这一块:
jadx --no-res --no-debug-info -j 8 -d /tmp/vivo-jadx vivo耳机.apk
grep -rn 'disconnectPosition' /tmp/vivo-jadx --include='*.java'
关键在 W4/a.java(混淆后的 DualConnectionManager):
x(payload, idx) → e(330, payload) // 330 = 0x014A 设置设备列表
y(enable, device) → e(332, payload) // 332 = 0x014C 开关多连接功能
removeDevice(...) → e(333, payload) // 333 = 0x014D 从耳机记录中移除设备
调用链是 e(cmd,data) → v(cmd,data) → b.J(cmd,data) → i(27, cmd, data),其中 27 = 0x1B 正是 vivo vendor,命令号不做任何偏移,可以直接用。
0x014A 的 payload 是全量列表,每台 7 字节 [MAC:6][state:1],不是单台指令——官方 App 也是这么做的,见 W4/a.java 里的 new byte[size * 7]。要断开某台,就把它的 state 置 0,然后连整张表一起发;其余设备应原样回传各自的状态字节,不能统一写成 1,写 1 会把当前活动设备从 0x02 降级为 0x01。
真机验证记录:下发把某台手机置为 02 后,它的状态字节由 00 → 01,并被移到列表首位(跟官方 updateDualConnectionDevices 里的重排逻辑一致),本机 Mac 保持 02 不受影响。0x014C 开关多连接和 0x014D 移除设备也已在真机上验证可用,其中 0x014D 的 payload 是 [目标MAC:6][本机MAC:6]。
耳机会主动发请求
有一点容易漏:耳机不只是被动应答,它会主动推两类帧,不应答可能影响后续交互。
0x8509 索要手机时间,主机需要回 0x0509,payload 是 7 字节纯二进制(不是 BCD):
[世纪, 年, 月, 日, 时, 分, 秒]
实测:14 1a 08 06 0b 0c 1d = 20,26,8,6,11,12,29 → 2026-08-06 11:12:29
按这个格式构造的应答帧,与官方 App 实际发出的字节逐字节相同,可以作为格式正确的交叉验证。
0x8224 埋点数据上报,payload 内嵌 JSON,直接吸收即可。这个 JSON 顺带很适合做交叉验证:里面的 "C":"85_94" 字段就是左右耳电量,可以用来核对 0x8207 的解析结果对不对。
代码怎么组织的
协议实现拆成了一个无 UI 依赖的核心库 VivoPodsKit,CLI 和 App 都复用它。这样做的好处是协议逻辑能脱离图形界面单独测试,也能被探测工具直接调用。

Sources/
├── VivoPodsKit/ 纯逻辑,无 UI 依赖,可被 CLI 与 App 复用
│ ├── Gaia/ 帧编解码(两种 wire format)、命令常量、Profile
│ ├── Parse/ 电量 / 降噪 / 握手 / 多连接设备解析
│ ├── Models/ 型号表、FastPair 广播解析
│ ├── Transport/ EarbudTransport 抽象 + BLETransport
│ └── Session/ 会话编排、耳机主动请求应答
├── VivoProbe/ 协议探测 CLI
├── VivoSelfTest/ 零依赖自测
└── VivoPodsApp/ MenuBarExtra 状态栏 App
围绕核心库有两个小工具。vivoprobe 是协议探测 CLI,对真机逐条发命令、打印原始收发字节、最后汇总哪些命令有回包,定位「命令号对不对」这类问题很快。vivopods-selftest 是零依赖自测,一部分断言向量直接移植自上游的 test_vivo.py,保证 Swift 版和已验证的 Python 版逐字节一致;另一部分是真机抓包,作为「GATT 无帧头」这个结论的回归保护。
这里有个环境上的约束值得一提:本机只装了 Command Line Tools,没有完整 Xcode,工具链里既没有 XCTest 也没有 swift-testing,import 两者都报 no such module,所以 swift test 用不了。测试于是写成了零依赖的自测可执行文件——思路虽然是被环境逼出来的,但和上游的纯标准库路线倒也一致。其中一条断言尤其关键:自己构造的时间应答帧,与官方 App 实际发出的字节逐字节相同,这就把「格式理解对了」这件事钉死了。
构建也不走 xcodebuild:swift build 产出可执行文件,再用脚本手工组装 .app(Info.plist 加可执行文件)然后 ad-hoc 签名。Info.plist 有两个关键项——LSUIElement=true 让它成为纯状态栏应用、不进 Dock,NSBluetoothAlwaysUsageDescription 是 CoreBluetooth 的硬性要求,缺了会被系统直接拒绝。bundle id 固定为 com.raybuild.vivopods,保证重新构建后 TCC 的蓝牙授权不会丢。
小结
VivoPods 这个项目,起点是一个很具体的麻烦:官方 App 因为一个 iOS 才成立的 A2DP 判定,在 Mac 上永远连不上耳机,而包又是加密的、改不动。绕过它的办法不是去修那个判定,而是回到更底层——链路本来就通,那就直接在 BLE GATT 上说协议。
真正的技术含量集中在协议这一层:GAIA 帧、vendor 0x001B、同一套命令跑两条通道但帧封装不同,以及电量、降噪、多连接这些 payload 的逐字节布局。逆向这种事,抓包告诉你「协议长什么样」,反编译补上「还有哪些命令没被触发」,真机验证则负责把每一条结论钉死——三者缺一,结论都会留有猜测的成分。
致谢与许可
协议实现移植自 TWS-Pods-PC(GPL-3.0-only),其上游是 HyperEars,vivo / iQOO 私有协议逆向的源头:GAIA 帧结构、命令号、wire profile、电量编码都出自那里。本项目同样以 GPL-3.0-only 发布。
软件仓库:vivopods-mac
协议逆向与真机验证回馈:TWS-Pods-PC #1

参与讨论
(Participate in the discussion)
参与讨论