深夜提醒

现在是深夜,建议您注意休息,不要熬夜哦~

🏮 🏮 🏮

新年快乐

祝君万事如意心想事成!

share-image
ESC

逆向蚂蚁阿福 × 沃莱 FG2508WB 蓝牙体脂秤

最近拿到一台蚂蚁阿福联名沃莱(ICOMON)的 FG2508WB 智能体脂秤,平时数据只能走蚂蚁阿福 App 查看。既然设备就在手边,不如直接用 Python 把蓝牙协议逆向出来,写一个脱离手机的读取脚本。

设备与目标

  • 设备:蚂蚁阿福 × 沃莱 FG2508WB 智能体脂秤
  • 连接方式:Bluetooth Low Energy(BLE)
  • 目标:用 Python + bleak 直接读取体重,并尽量解析体成分数据

体脂秤底部标签

环境准备

在 Linux 上只要系统蓝牙可用,安装 bleak 即可:

pip install bleak

检查本机蓝牙控制器:

hciconfig -a

扫描与连接

体脂秤为了省电,平时不广播,轻踩一下才会唤醒。用 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 位大端整数

bytes[10:13] = 69 06 b2

换算公式:

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 之间变化,属于正常现象。

蚂蚁阿福 App 测量结果

阻抗与体成分

数据包中 bytes[4:5]bytes[6:7] 看起来是两个频率下的阻抗值:

02 3f = 575
02 07 = 519

体脂率、水分率、BMI、骨量、肌肉量等指标并没有直接编码在蓝牙数据包里,而是由 App 端根据「体重 + 阻抗 + 身高/年龄/性别」通过 BIA 算法计算出来的。不同厂商的算法系数不同,我用公开 BIA 公式套出来的结果和蚂蚁阿福 App 有偏差,所以目前脚本只输出体重和原始阻抗值。

完整脚本

完整脚本也已放在本仓库 tools/afu_scale_reader.py,复制后修改 DEVICE_ADDRESS 即可运行。

#!/usr/bin/env python3
"""
蚂蚁阿福 × 沃莱 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" # 替换为你的秤 MAC
USER_SEX = 2 # 1=女, 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 字节数据包结构是共性特征。

如果你也对这台秤做过逆向,或者知道体脂算法的具体系数,欢迎留言交流。

文章作者:阿文
文章链接: https://www.awen.me/post/cf4a68bd.html
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 阿文的博客

评论

0 条评论
😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘 🛑📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭 ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️ 🈯💹❇️ ✳️🌐 💠Ⓜ️🌀 💤🏧🚾 🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️
加载中...

留言反馈

😀😃😄 😁😅😂 🤣😊😇 🙂🙃😉 😌😍🥰 😘😗😙 😚😋😛 😝😜🤪 🤨🧐🤓 😎🥸🤩 🥳😏😒 😞😔😟 😕🙁☹️ 😣😖😫 😩🥺😢 😭😤😠 😡🤬🤯 😳🥵🥶 😱😨😰 😥😓🤗 🤔🤭🤫 🤥😶😐 😑😬🙄 😯😦😧 😮😲🥱 😴🤤😪 😵🤐🥴 🤢🤮🤧 😷🤒🤕 🤑🤠😈 👿👹👺 🤡💩👻 💀☠️👽 👾🤖🎃 😺😸😹 😻😼😽 🙀😿😾 👍👎👏 🙌👐🤲 🤝🤜🤛 ✌️🤞🤟 🤘👌🤏 👈👉👆 👇☝️ 🤚🖐️🖖 👋🤙💪 🦾🖕✍️ 🙏💅🤳 💯💢💥 💫💦💨 🕳️💣💬 👁️‍🗨️🗨️🗯️ 💭💤❤️ 🧡💛💚 💙💜🖤 🤍🤎💔 ❣️💕💞 💓💗💖 💘💝💟 ☮️✝️☪️ 🕉️☸️✡️ 🔯🕎☯️ ☦️🛐 🆔⚛️🉑 ☢️☣️📴 📳🈶🈚 🈸🈺🈷️ ✴️🆚💮 🉐㊙️㊗️ 🈴🈵🈹 🈲🅰️🅱️ 🆎🆑🅾️ 🆘 🛑📛 🚫💯💢 ♨️🚷🚯 🚳🚱🔞 📵🚭 ‼️⁉️🔅 🔆〽️⚠️ 🚸🔱⚜️ 🔰♻️ 🈯💹❇️ ✳️🌐 💠Ⓜ️🌀 💤🏧🚾 🅿️🈳 🈂🛂🛃 🛄🛅🛗 🚀🛸🚁 🚉🚆🚅 ✈️🛫🛬 🛩️💺🛰️