最近拿到一台蚂蚁阿福联名沃莱(ICOMON)的 FG2508WB 智能体脂秤,平时数据只能走蚂蚁阿福 App 查看。既然设备就在手边,不如直接用 Python 把蓝牙协议逆向出来,写一个脱离手机的读取脚本。
设备与目标
- 设备:蚂蚁阿福 × 沃莱 FG2508WB 智能体脂秤
- 连接方式:Bluetooth Low Energy(BLE)
- 目标:用 Python +
bleak 直接读取体重,并尽量解析体成分数据

环境准备
在 Linux 上只要系统蓝牙可用,安装 bleak 即可:
检查本机蓝牙控制器:
扫描与连接
体脂秤为了省电,平时不广播,轻踩一下才会唤醒。用 BleakScanner 扫描,设备名通常是 AFU-WL-TZ-A1:
from bleak import BleakScanner
devices = await BleakScanner.discover(timeout=10.0) for d in devices: print(d.address, d.name)
|
连接后发现 GATT 服务结构如下:
| Service |
Characteristic |
属性 |
0000ffb0-... |
0000ffb1-... |
write-without-response |
0000ffb0-... |
0000ffb2-... |
notify |
00001530-... |
00001531-... |
write, notify |
实际数据从 0000ffb2 的 notify 里出来。
数据包结构
连接后写入用户参数(性别/年龄/身高)触发测量:
cmd = bytes([0x10, 0x00, sex, age, height]) await client.write_gatt_char(WRITE_UUID, cmd, response=False)
|
站上秤后,秤会不断通过 notify 发送 20 字节数据包。常见的 type 有:
0x00:实时重量
0x80:稳定但未锁定
0x02:最终结果已锁定
一个典型的最终结果包长这样:
ac 29 02 00 02 3f 02 07 01 80 69 06 b2 00 00 00 00 00 29 d6 0d
|
体重解析
经过对多组数据做对比(空秤、站上人体、放牛奶负重),发现 体重保存在 bytes[10:13],是一个 24 位大端整数:
换算公式:
weight_raw = int.from_bytes(data[10:13], "big") weight_kg = (weight_raw - 0x680000) / 1000.0
|
验证
| 场景 |
真实重量 |
bytes[10:13] |
计算结果 |
| 牛奶 |
13.75 kg |
68 35 b6 |
13.75 kg |
| 人体 |
67.30 kg |
69 06 b2 |
67.25 kg |
两次都吻合,误差在 0.05 kg 以内,正好是这台秤标称的分度值。家用体脂秤每次称重本来就会有轻微波动,同一个人连续测量也可能在 0.05~0.1 kg 之间变化,属于正常现象。

阻抗与体成分
数据包中 bytes[4:5] 和 bytes[6:7] 看起来是两个频率下的阻抗值:
体脂率、水分率、BMI、骨量、肌肉量等指标并没有直接编码在蓝牙数据包里,而是由 App 端根据「体重 + 阻抗 + 身高/年龄/性别」通过 BIA 算法计算出来的。不同厂商的算法系数不同,我用公开 BIA 公式套出来的结果和蚂蚁阿福 App 有偏差,所以目前脚本只输出体重和原始阻抗值。
完整脚本
完整脚本也已放在本仓库 tools/afu_scale_reader.py,复制后修改 DEVICE_ADDRESS 即可运行。
""" 蚂蚁阿福 × 沃莱 FG2508WB 体脂秤蓝牙读取脚本
体重协议(已验证): 最终结果包 type=0x02,bytes[10:13] 为 24 位大端整数 weight_kg = (val - 0x680000) / 1000
阻抗协议(待进一步校准): bytes[4:5]、bytes[6:7] 可能为两个频率下的阻抗值(Ω) """
import asyncio import json import time from pathlib import Path from bleak import BleakScanner, BleakClient
DEVICE_ADDRESS = "XX:XX:XX:XX:XX:XX" USER_SEX = 2 USER_AGE = 35 USER_HEIGHT = 172 SAVE_DIR = Path.home() / ".local" / "share" / "afu_scale"
NOTIFY_UUID = "0000ffb2-0000-1000-8000-00805f9b34fb" WRITE_UUID = "0000ffb1-0000-1000-8000-00805f9b34fb"
latest = {"weight": None, "impedance_1": None, "impedance_2": None, "locked": False}
def parse_measurement(data: bytearray) -> dict: result = {"type": data[2], "raw": data.hex()} if len(data) >= 13 and data[0] == 0xAC: weight_raw = int.from_bytes(data[10:13], "big") result["weight"] = round((weight_raw - 0x680000) / 1000.0, 2) if len(data) >= 8: result["impedance_1"] = int.from_bytes(data[4:6], "big") result["impedance_2"] = int.from_bytes(data[6:8], "big") result["locked"] = data[2] == 0x02 return result
def notification_handler(sender, data: bytearray): parsed = parse_measurement(data) if "weight" in parsed: latest.update(parsed) status = "✓ 已锁定" if parsed.get("locked") else "... 称重中" print( f"[{time.strftime('%H:%M:%S')}] {status} " f"体重={parsed['weight']:.2f}kg " f"阻抗={parsed.get('impedance_1')}/{parsed.get('impedance_2')}Ω" )
async def scan_for_device(): print("正在扫描体脂秤,请轻踩一下秤让它唤醒...") for attempt in range(3): devices = await BleakScanner.discover(timeout=10.0) for d in devices: name = (d.name or "").lower() if d.address == DEVICE_ADDRESS or "afu-wl" in name: print(f"找到设备: {d.address} | {d.name}") return d.address print(f"第 {attempt + 1} 次扫描未找到,重试...") raise RuntimeError("未找到体脂秤")
async def main(): address = await scan_for_device() async with BleakClient(address, timeout=20.0) as client: print("已连接,请站上秤并保持稳定...") await client.start_notify(NOTIFY_UUID, notification_handler) cmd = bytes([0x10, 0x00, USER_SEX, USER_AGE, USER_HEIGHT]) await client.write_gatt_char(WRITE_UUID, cmd, response=False)
for _ in range(30): await asyncio.sleep(1) if latest.get("locked"): break
try: await client.stop_notify(NOTIFY_UUID) except Exception: pass
if latest.get("weight") is not None: SAVE_DIR.mkdir(parents=True, exist_ok=True) record = { "time": time.strftime("%Y-%m-%d %H:%M:%S"), "weight_kg": latest["weight"], "impedance_1": latest.get("impedance_1"), "impedance_2": latest.get("impedance_2"), "raw": latest["raw"], } save_path = SAVE_DIR / f"scale_{time.strftime('%Y%m%d_%H%M%S')}.json" save_path.write_text(json.dumps(record, ensure_ascii=False, indent=2)) print(f"\n测量完成: 体重 {latest['weight']:.2f}kg") print(f"数据已保存: {save_path}") else: print("\n未读到有效体重数据")
if __name__ == "__main__": asyncio.run(main())
|
运行:
python afu_scale_reader.py
|
实际运行效果如下,从扫描、称重到锁定大约 3~5 秒:

小结
- 体重可以直接从 BLE 数据包中解析出来,公式为
(bytes[10:13] - 0x680000) / 1000。
- 体脂、水分、BMI 等体成分数据需要额外的 BIA 算法,目前仍在校准中。
- 不同批次的沃莱/蚂蚁阿福秤可能协议细节不同,但服务 UUID
0000FFB0 和 20 字节数据包结构是共性特征。
如果你也对这台秤做过逆向,或者知道体脂算法的具体系数,欢迎留言交流。
评论
0 条评论