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 没有任何关联。硬件为自行购买,协议是在我自己的 网络上观察到的,未对官方应用程序的任何代码进行反编译。
标签:云资产清单, 物联网, 蓝牙低功耗, 计算机取证, 运动健康, 逆向工具, 逆向工程