Sinepel/citysports-ble-strava
GitHub: Sinepel/citysports-ble-strava
一个通过逆向专有 BLE 协议将 CITYSPORTS 走步机连接到 Strava 并自动记录运动数据的 macOS 桥接工具。
Stars: 0 | Forks: 0
# citysports-ble-strava
一个用于连接 CITYSPORTS 走步机与 Strava 的桥接工具,适用于 macOS。
该走步机既不支持 FTMS 也不支持 RSC,仅提供了一个没有任何公开文档的
专有 BLE 服务。该协议于 2026/07/27 通过 nRF Connect 捕获数据和
Android HCI 日志反向还原。据我所知,本代码库是记录该协议的
唯一之处。
该应用程序会读取走步机的计数器,切分运动记录,生成 TCX
文件并将其发布到 Strava 上。它还能控制走步机:启动、停止
和设定速度。
预期用途:在办公时间进行慢走,每天多次运动,
并通过菜单栏在后台进行记录。
## 协议
只有一个服务,两个特征值:
```
service ffeeddcc-bbaa-9988-7766-554433221100
...1102 NOTIFY (+ CCCD 0x2902) tapis vers app
...1101 WRITE (avec reponse) app vers tapis
```
双向的数据包封装结构相同,仅报头不同:
```
| | | |
```
走步机发给应用为 `0x1A`,应用发给走步机为 `0xA1`。
### 接收的数据帧
**类型 0x01,状态**(9 字节 payload)
| 字节 | 含义 |
|---|---|
| p0 | 最大速度,单位 0.1 km/h (120) |
| p1 | 最小速度,单位 0.1 km/h (10) |
| p4 | 当前速度,单位 0.1 km/h |
| p6 | 状态:`6` 待机,`1` 倒计时,`2` 运行中 |
**类型 0x02,计数器**(12 字节 payload,四个大端序 16 位整数)
| 偏移量 | 含义 |
|---|---|
| p0p1 | 已经过时间,单位秒 |
| p2p3 | 距离,单位米 |
| p4p5 | 卡路里 |
| p6p7 | 步数 |
这两种类型会持续交替发送,每种每秒约发送 3 帧数据。
`0x02` 数据帧**不**包含速度:如果将其缺少速度视为速度为零,
会导致所有基于积分的计算产生错误。
### 发送的数据帧
| 数据帧 | 含义 |
|---|---|
| `A1 05 00 A4` | 握手,在订阅通知后立即发送 |
| `A1 03 01 01 A2` | 启动 |
| `A1 03 01 05 A6` | 停止 |
| `A1 01 02 01 ` | 设定速度,`v` 单位为 0.1 km/h |
接受的边界值:10 到 120,即 1.0 到 12.0 km/h。这些值是
走步机自身通过状态帧的 p0 和 p1 通告的。
控制台难以承受剧烈的速度突变,因此在 `go_to_speed` 中
实现了每 0.4 秒增加 0.5 km/h 的斜坡变化。
## 安装
```
pip3 install -r requirements.txt
python3 strava_setup.py # assistant OAuth, ecrit ~/.citysports_strava.json
```
向导会要求输入在 https://www.strava.com/settings/api 上创建的
应用的 Client ID 和 Client Secret。生成的文件会被设为 `chmod 600` 权限,
并且永远不会被纳入版本控制。
在 macOS 上,请允许“终端”访问系统设置中的“隐私与安全性”下的
蓝牙权限。如果没有此权限,扫描过程不会报告任何错误,
只是永远无法搜索到任何设备。
## 用法
```
# 读取 decoded frames,不记录
python3 citysports_strava.py --calibrate
# Capture 不 upload Strava
python3 citysports_strava.py --test
# 正常模式
python3 citysports_strava.py
# 菜单栏界面
python3 citysports_menubar.py
# 单次命令,用于测试 pilotage
python3 citysports_control.py stop
python3 citysports_control.py speed 2.0
```
为了防止在长时间行走时系统进入休眠:
```
caffeinate -i python3 citysports_strava.py
```
构建 macOS bundle:
```
./build.sh
```
## 文件
| 文件 | 作用 |
|---|---|
| `citysports_strava.py` | 核心引擎:BLE、协议、运动记录、TCX、Strava |
| `citysports_menubar.py` | macOS 菜单栏界面 (rumps) |
| `setup.py` | py2app 构建配方 |
| `build.sh` | 构建 bundle,仅限 macOS |
| `strava_setup.py` | OAuth 向导 |
| `citysports_control.py` | 发送单一命令 |
| `citysports_probe.py` | 探测候选命令 |
| `btsnoop_extract.py` | 从 Android HCI 日志中提取 ATT 数据 |
`citysports_strava.py` 是独立运行的。`citysports_menubar.py` 将其作为
模块导入,并挂载到它的 `on_update`、`on_session_end` 和
`on_connection` 回调上。BLE 引擎在具有自己独立的 asyncio 循环的
单独线程中运行,而 rumps 占用主 AppKit runloop。
输出文件存放在 `~/Documents/CitySports/`。
## 已知陷阱
**同一时间只能建立一个 BLE 连接。** 如果手机或 nRF Connect 仍处于
连接状态,Mac 将永远无法连接。这是导致设备检测失败的
首要原因。
**始终通过名称进行扫描。** MAC 地址只能在 Android 上看到,
CoreBluetooth 暴露的是每台机器自有的内部 UUID。
**走步机从其自身启动时开始计数。** 如果在运动中途开始捕获数据,
其计数器已经发生了前移。`Session.update` 会记录在第一个数据点时的
数值,并以相对值进行计算。
**走步机可能会在捕获过程中将其计数器重置为零。** 当检测到
数值倒退时,即触发原点重置。
## Strava 相关特性
**Strava 会根据数据点流计算摘要,而不是根据 Lap 计算。**
已在真实运动数据上验证:Lap 为 137 m 和 338 s,但显示的摘要为 94 m 和
185 s,因为第一个数据点已经是 43 m 了。因此需要插入由 `tcx_points()` 生成的
合成起始点。
**TCX 的 Sport 属性值为 `Running`,而不是 `Walking`。** 这是 Strava 能够
解析 `ns3:RunCadence` 的唯一值。实际类型会在上传后立刻通过 PUT 请求
更正为 `Walk`,并在此过程中再次声明 `trainer=1`。
**没有步数字段。** 步数通过 cadence 传递,根据 Garmin 的约定
以每分钟的步频表示,即每分钟步数的一半。Strava 在显示时会将其翻倍。
总步数也会显示在描述中。
## 状态
已实现功能:读取、运动记录切分、TCX 生成、Strava 上传、速度控制、菜单栏。
已开展的方向:
- 通过 `pystray` 替代 `rumps` 以移植到 Windows 和 Linux,因为核心引擎
已经是可跨平台的
- 上传队列,确保在断网时数据也不会丢失
- 菜单中的每日累计数据
- 自动分段模式
- 将协议发送给 Roberto Viola 以整合到 QZ (qdomyos-zwift) 中,
这将有望解锁 Zwift 和 Garmin
## 许可证
MIT。
与 CITYSPORTS 没有任何关联。硬件为自行购买,协议是在我自己的
网络上观察到的,未对官方应用程序的任何代码进行反编译。
标签:云资产清单, 物联网, 蓝牙低功耗, 计算机取证, 运动健康, 逆向工具, 逆向工程