{"version":"https://jsonfeed.org/version/1.1","title":"小羊的博客","home_page_url":"https://blog.madeai.eu.org","feed_url":"https://blog-madeai-eu-org.pages.dev/json/","description":"<p>分享日常工作生活中的所思所想，拒绝碎片化信息，通过阅读和博客沉淀深度思考。</p>","icon":"https://cdn.madeai.eu.org/blog-madeai-eu-org/production/images/channel-b4ee93e4522a2b7a4690f12bf5d4960a.png","favicon":"https://cdn.madeai.eu.org/blog-madeai-eu-org/production/images/favicon-6920fee38a77ad3a22da59b4571d4660.png","authors":[{"name":"DylanYang"}],"language":"zh-cn","items":[{"id":"BJndgcYhWBB","title":"逆向小米运动健康：从抓包到生成 FIT 文件","content_html":"<h2>起因</h2>\n<p>我用小米手环记录运动已经快两年了。某天想把骑行数据导进 Strava 做路线分析，才发现问题：官方 App 的导出能力相当有限，而云端其实存着远比界面展示更丰富的原始数据——逐秒心率、速度、踏频、GPS 轨迹、甚至游泳每一趟的配速和划水次数，全都躺在服务器上，只是没有出口。</p>\n<p>于是我决定自己做这个出口。最终成果是一个本地 Web 应用 <code>~/mifit-web</code>（Flask + 离线自包含前端），能直接对接小米运动健康后端，拉取 → 解密 → 解析 → 生成标准 <strong>.fit</strong> 文件，也能在网页上看完整的数据分析和轨迹地图。</p>\n<p>这篇文章记录整个逆向过程——包括那些让我卡了好几个小时的坑。如果你也在做类似的 App 数据自救，希望这些经验能省你一些时间。</p>\n<p><strong>先说结论</strong>：这套流程目前跑通了 <strong>416 条运动记录</strong>，覆盖 <strong>2024-10 到 2026-09 共 23 个月</strong>、<strong>18 种运动类型</strong>，解析出的时长、心率、卡路里与官方报表<strong>零条不匹配</strong>。</p>\n<h2>第一层：抓包</h2>\n<p>目标 App <code>com.xiaomi.miwatch.pro</code>，后端 <code>hlth.io.mi.com</code>。iOS 上我用 HTTP Catcher 抓包，产物是 <code>.hcf</code> 文件。</p>\n<h3>坑：一条请求被拆成多条记录</h3>\n<p>解析 <code>.hcf</code> 时第一个意外是：<strong>HTTP 头在一条记录里，加密参数的表单却在紧随其后的另一个分片里。</strong>记录头格式是：</p>\n<pre><code>00 00 0c 05  (请求) / 00 00 0c 08  (响应)\n+ reqseq(4) + ts(8, 毫秒) + paylen(4)</code></pre>\n<h3>坑：绑定顺序必须按「文件位置」，不能按「时间序」</h3>\n<p>要把请求行和它的参数表单配对。我一开始按时间顺序配对，签名校验率只有 <strong>47%</strong>——因为相邻请求的表单会互相串位。</p>\n<p>改成<strong>按文件位置划窗口</strong>，再用「哪个候选的 <code>data</code> 能解出合法 JSON」来定绑，校验率立刻升到 <strong>79%</strong>。</p>\n<p>还有个很隐蔽的坑：提取 <code>_nonce</code> 的正则如果写成 <code>_nonce=([^\\x00\\r\\n ]+)</code>，会<strong>贪婪地吞掉后面整个查询串</strong>——实测 nonce 变成了 287 个字符。结果是所有候选都不满足「nonce 长 12/16」而被丢弃，表现为「表单 0 个、请求 0 条」，可正则明明匹配到 21 处。正确写法必须排除 <code>&amp;</code>：</p>\n<pre><code>_nonce=([^\\x00\\r\\n &amp;]+)</code></pre>\n<p>另外 <strong>token 有域的概念</strong>：抓包里会同时出现两个 <code>serviceToken</code>——<code>hlth</code> 域的约 172 字符，<code>api.io.mi.com</code> 的约 216 字符。取错就一直是 <code>auth err</code>。</p>\n<h2>第二层：签名算法</h2>\n<p>小米这套接口的每个请求都带三个加密参数：<code>data</code>（业务数据密文）、<code>rc4_hash__</code>、<code>signature</code>。要自己构造请求，必须完全复现它的算法。反编译 APK 后我一点点把逻辑拼了出来，其中最要命的一条是这个：</p>\n<h3>致命坑：RC4 必须跳过前 1024 字节 keystream</h3>\n<p>看 <code>esj</code> 类的构造函数结尾有一句 <code>b(f17415b)</code>，而 <code>f17415b</code> 是一个 <code>new byte[1024]</code> 的全零数组——等于用 1024 个 <code>0x00</code> 去预热 RC4 状态机。</p>\n<p><strong>所以实际 keystream 是从第 1024 字节开始的。</strong>如果按标准 RC4 从 0 开始取，得到的是高熵垃圾，还会误判成「security 不对」，一路往错误方向排查。这是整件事里最大的时间黑洞。</p>\n<h3>另外三个容易搞错的点</h3>\n<ul>\n<li><strong><code>t12.i()</code> 是标准 Base64，不是 hexdigest。</strong>判据：<code>signature</code> 密文 28 字符（= 20 字节 SHA1），<code>rc4_hash__</code> 密文 40 字符（= 28 字节）。如果你算出 40 字符的十六进制串，说明用错了 <code>hexdigest()</code>。</li>\n<li><strong>一条请求内多个参数共享同一条 RC4 keystream</strong>（连续累加）。<code>y54.c</code> 只 <code>new esj(strB)</code> 一次，然后按 TreeMap 顺序切分：<code>data</code> 占 <code>K[0:n]</code>，<code>rc4_hash__</code> 接着 <code>K[n:]</code>。只单独解 <code>data</code> 看不出问题，校验第二个参数时才会暴露。</li>\n<li><strong><code>rc4_hash__</code> 用明文 kv 算，<code>signature</code> 用密文 kv 算。</strong>两者都不包含 <code>_nonce</code> / <code>signature</code> / <code>rc4_hash__</code>；而 <code>data</code> 自身不在签名 kv 里，是以字面量 <code>data=&lt;值&gt;</code> 的形式拼进签名串的。</li>\n</ul>\n<h3>完整算法</h3>\n<pre><code>raw32 = SHA256( b64decode(security) || b64decode(_nonce) )   # 32 字节\nstrB  = base64encode(raw32)\n\n# y54.c —— GET/POST 通用（注意：不是 y54.d）\nrc4_hash__ = b64(SHA1( METHOD &amp; path &amp; \"k=v\"按key排序 &amp; strB ))   # 明文 kv\nrc4 = RC4(raw32, skip=1024)                                    # 整条请求只建一次！\nfor k in sorted(treeMap):\n    out[k] = b64( rc4.xor(utf8(treeMap[k])) )\nsignature = b64(SHA1( METHOD &amp; path &amp; \"k=v\"按key排序 &amp; strB ))   # 密文 out\nout[\"_nonce\"] = nonce</code></pre>\n<p><code>_nonce</code> 的构造是 <code>base64( 8字节随机 + int32_be((now+timeDiff)//60000) )</code>，共 16 字符——末 4 字节是大端分钟数，正好可以用来核对抓包时间。</p>\n<p>还有个必须知道的事实：<strong><code>security</code> 会随登录变化</strong>，必须用与目标请求同时段的那个。来源是响应头 <code>extension-pragma</code> 里的 <code>ssecurity</code>，或登录响应 JSON 的 <code>data.ssecurity</code>。</p>\n<h2>第三层：三个把 401 变 200 的关键</h2>\n<p>算法复现对了（重新加密能和抓包原值逐字符比对一致），但请求发出去仍然是 401。又踩了三个坑才通：</p>\n<h3>A. 请求参数是「单个 data 字段」，值是整段 JSON 字符串</h3>\n<pre><code># ✓ 对\nkv = {\"data\": '{\"watermark\":0,\"limit\":30}'}\n# ✗ 错（会一直 401！）—— 把 JSON 字段拆成独立参数\nkv = {\"watermark\": 0, \"limit\": 30}</code></pre>\n<p>拆开算签名，服务端一律返回 <code>auth err</code>。验证方法：用已知明文重新加密，和抓包原值逐字符比对，三个字段必须全对。</p>\n<h3>B. <code>@Secret(pathPrefix=\"healthapp/\")</code> → 签名要用裁剪后的 subpath</h3>\n<p>规则在 <code>CloudInterceptor.subpath</code>：如果 <code>pathPrefix</code> 非空，就取 URL 中该子串出现位置<strong>之后</strong>的部分；为空则从第一个 <code>/</code> 起取。</p>\n<pre><code>URL   /healthapp/service/gen_download_url\n签名  /service/gen_download_url        ← 用这个算 signature / rc4_hash__</code></pre>\n<p>同理 <code>/healthapp/home/profiles</code> → <code>/home/profiles</code>。而 <code>/app/v1/*</code>、<code>/ops/v1/*</code> 的 pathPrefix 为空，用完整路径。<strong>这一条解开后，401 立刻变 200。</strong></p>\n<h3>C. 响应解密用「请求同一个 nonce」，且有三种形态</h3>\n<p>要按顺序试：① 明文 JSON（不加密的端点）；② gzip 包着的 base64 密文（chunked 响应）；③ <strong>直接就是 base64 密文</strong>（非 chunked 响应，实时 API 常见）。</p>\n<h2>第四层：原始文件的加密</h2>\n<p>拿到接口还不够——真正的原始数据（逐秒心率、GPS 轨迹）是通过一个文件下载接口拿的，返回的是加密文件。我一度以为解不开，后来发现<strong>密钥就明晃晃地放在 <code>gen_download_url</code> 的响应里</strong>：</p>\n<pre><code>{\"result\": {\"&lt;suffix&gt;_&lt;ts&gt;\": {\n   \"url\": \"https://cnbj1.fds.api.xiaomi.com/...\",\n   \"obj_key\": \"<em>[REDACTED]</em>\",   ← AES 密钥在这里\n   \"method\": \"GET\"}}}</code></pre>\n<p>反编译确认了 <code>AESCoder(String aesKey) { this(Base64.decode(aesKey, 8)); }</code>，于是：</p>\n<pre><code>key  = base64.urlsafe_b64decode(obj_key + '=' * (-len(obj_key) % 4))   # 16 字节\nIV   = b'1234567887654321'          # AESCoder.getInitialVector() 硬编码\nalgo = AES/CBC/PKCS5Padding\n\nct  = base64.urlsafe_b64decode(txt + '=' * (-len(txt) % 4))\nraw = AES_CBC_decrypt(key, IV, ct)  然后 PKCS5 unpad</code></pre>\n<p><strong>每份文件的 <code>obj_key</code> 各不相同</strong>（GPS 文件和打点文件的密钥不一样），必须各自取用，不能复用。</p>\n<h3>坑：CDN 返回体有两种形态</h3>\n<pre><code>① 裸 base64url，'=' padding 被剥掉        b'iLgArCVs8Jn...c'\n② JSON 字符串（带双引号），同样无 padding  b'\"4feFfXCJLHKw...q\"'</code></pre>\n<p>只 <code>.strip()</code> 去不掉引号。而更阴的是——<strong>引号恰好让长度凑成了 4 的倍数</strong>，于是代码跳过了补 padding 的逻辑，解码时长度又少 2 → 抛 <code>Incorrect padding</code>。</p>\n<p>症状很有意思：一部分活动（乒乓球、腰部训练、钓鱼……）详情页直接报错，另一部分完全正常。原因是这两类活动走的分支不同。修法是先剥引号、再按真实长度补 <code>=</code>。</p>\n<h2>第五层：二进制格式解析</h2>\n<p>解密后得到的是 schema 驱动的二进制。解析器在 APK 里是 <code>com/xiaomi/fit/fitness/parser/schema/</code> 那一套。以 GPS 文件为例，头部 9 字节：</p>\n<pre><code>[0:4] timestamp u32LE   [4] timezone u8   [5] version u8\n[6]   fileType   bitField: normalDataFlag(7) dataType(6..2) fileType(1..0)\n[7]   encComp    bitField: encryptionMode(7..4) compressionMode(3..0)\n[8]   validity   bitField: time(7) lon(6) lat(5) acc(4) speed(3) src(2) alt(1) hdop(0)</code></pre>\n<p>点字段<strong>必须按 schema 的固定偏移，不能顺次臆测</strong>：</p>\n<pre><code>[0:4]   time                          u32LE\n[4:8]   longitude                     f32LE\n[8:12]  latitude                      f32LE\n[12:16] locationHorizontalAccuracy    f32LE   ← 水平精度，不是海拔！\n[16:18] locationSpeedAndGpsSource     u16LE   speed=(v&gt;&gt;4)/10 m/s, src=v&amp;0x0F\n[18:22] altitude                      f32LE\n[22:26] hdop                          f32LE</code></pre>\n<p>我一开始把 <code>[12:16]</code> 当海拔，结果拿到的是水平精度——平坦路恒为 4.75m，定位野值点跳到 1448，看着像条曲线，其实毫无物理意义。这类「看着合理但完全错误」的 bug 最难发现。</p>\n<h3>核心认知：结构是变的，绝不写死字节数</h3>\n<p>同一个 App 导出的文件，因为 <code>validityFlags</code> 决定哪些字段被写入，<strong>布局逐个活动都不同</strong>：</p>\n<table>\n<tr><th></th><th>骑行</th><th>徒步</th></tr>\n<tr><td>GPS 点长</td><td>18 B</td><td>18 B</td></tr>\n<tr><td>record 头部</td><td>11 B</td><td>13 B</td></tr>\n<tr><td>record 点长</td><td>7 B</td><td>13 B</td></tr>\n<tr><td>速度在哪</td><td>record 里 (u16LE@4, ×0.1)</td><td>record 里没有，只有 GPS 有</td></tr>\n<tr><td>里程单位</td><td>米 (÷1)</td><td>0.1 米 (÷10)</td></tr>\n</table>\n<p>所以解析器只能<strong>自适应探测</strong>，而关键是——用什么当校验信号？答案是<strong>官方报表</strong>。报表里的 <code>distance</code> / <code>duration</code> / <code>avg_hrm</code> / <code>max_hrm</code> / <code>calories</code> 就是标准答案：</p>\n<ul>\n<li><strong>GPS</strong>：枚举点长 P，要求 <code>body % P == 0</code>，且时间戳单调不减、坐标聚在一小片区域、速度物理合理。</li>\n<li><strong>record</strong>：枚举 <code>(头部长度, 段头长度, 段头内偏移, 点长)</code>，要求<strong>段链恰好走到 EOF</strong>，再加「里程合计 ≈ 报表 distance」「点数 ≈ 报表 duration」「心率列均值/最大 ≈ 报表」三重打分。</li>\n</ul>\n<h3>坑：段链定位不能靠「第一个像时间戳的值」</h3>\n<p>record 文件是多个段拼接的，结构是「段头 + N 个点」循环直到 EOF。段头里 <code>resumeTimestamp</code> 是段的起始时间。</p>\n<p>如果从 offset 0 开始扫「第一个像时间戳的 u32」，会命中段头里由掩码/里程高位拼出的<strong>伪值</strong>（实测 <code>0x739e0000</code>），于是把 814 秒的骑行算成 <strong>65011721 秒</strong>。</p>\n<p>阴险的地方在于：段链本身没坏，段数/点数/里程全对，<strong>只有时长崩了</strong>，极易漏掉。正确判据要三个同时成立：</p>\n<pre><code>vals = [u32le(head, off) for each seg head]\nall(MIN_UNIX &lt; v &lt; MAX_UNIX for v in vals)      # 全部落在合理 unix 区间\nall(b &gt;= a for a, b in zip(vals, vals[1:]))      # 跨段非递减\nall(vals[i+1]-vals[i] &gt;= rcs[i]*0.95)            # 后段起点 ≥ 前段起点 + 前段点数</code></pre>\n<p>骑行与徒步实测都落在 <strong>offset 8</strong>。</p>\n<h3>坑：duration 和 elapsed 是两回事</h3>\n<table>\n<tr><th>指标</th><th>含义</th><th>实测对齐</th></tr>\n<tr><td>报表 <code>duration</code></td><td>实际运动秒数（不含暂停）= 文件点数</td><td>徒步 12745 = 12745 ✓</td></tr>\n<tr><td><code>end_time - start_time</code></td><td>墙钟跨度（含暂停）</td><td>骑行 1607 s ✓</td></tr>\n</table>\n<p>拿 <code>end_time - start_time</code> 去校验 <code>duration</code> 会误判成 bug（骑行 1001 vs 814，看着差 23%），其实两个都对。FIT 里分别对应 <code>total_timer_time</code> 和 <code>total_elapsed_time</code>。</p>\n<h2>第六层：游泳分段指标的破解</h2>\n<p>这是最近才攻下的部分，也是最花时间的一块。</p>\n<p>小米 App 在游泳详情里会画出「分段配速」「分段划频」「分段 SWOLF」三张图。我想知道这些是本地算的还是服务端给的。反编译定位到 <code>SportRecordConverter.java</code>，它构造 <code>SwimmingPassage</code> 对象时读的是 <code>oneSportRecord.getPace()</code> / <code>getSwolf()</code>——<strong>来自服务端下发的 record 字段，不是本地计算的</strong>。wire 级字段名是 <code>sectionPace</code> / <code>sectionSwolf</code> / <code>sectionStrokeFrequency</code>。</p>\n<p>但把 <code>get_sport_records_by_time</code> 的完整响应翻遍，<code>value</code> 里只有汇总字段：<code>avg_pace</code>、<code>max_pace</code>、<code>avg_swolf</code>、<code>best_swolf</code>、<code>stroke_count</code>、<code>turn_count</code>……<strong>没有任何 section 数组</strong>。</p>\n<p>关键线索来自 schema 文件 <code>swimming_record_v3.json</code>——它的 <code>dataPoint</code> 里明确声明了 <code>sectionPace</code> / <code>sectionSwolf</code> / <code>sectionStrokeFrequency</code>，而且<strong>字节大小是写明的</strong>。</p>\n<h3>为什么按 schema 硬套永远对不上</h3>\n<p>APK 里那个 schema 声明的是 <strong>version 8.0</strong>，dataPoint 约 35 字节。而实际文件是 <strong>version 2</strong>、每小节 <strong>26 字节</strong>——字段增删过。按 schema 硬套只会一路错位，表现为 <code>sectionDistance</code> 读出 65280 这种荒谬值。</p>\n<h3>突破：放弃固定布局，改用「扫时间戳 + 值域筛选」重新同步</h3>\n<p>实测出来的 26 字节布局：</p>\n<pre><code>+0   endTime                  u32   本节结束时间\n+4   dataType                 u8    0=小节 / 1、2=段落汇总\n+5   sectionPace              u16   秒/百米       ★ 分段配速\n+7   sectionSwolf             u16   SWOLF         ★ 分段 SWOLF\n+9   sectionDistance          u16   本小节距离(米，=池长)\n+11  activeCalories           u16\n+13  totalArmPull             u16   本小节划水次数\n+15  turnCount                u16   本小节转身次数\n+17  sectionStrokeFrequency   u8    本小节划频     ★ 分段划频\n+18  unknownStrokeFreq        u8\n+19  breastStrokeFreq         u8\n+20  freeStrokeFreq           u8\n+21  backStrokeFreq           u8\n+22  butterflyStrokeFreq      u8\n+23  totalCalories            u16\n+25  标志位                   u8</code></pre>\n<p>真正的障碍是：<strong>每约 4 个小节后跟一条变长的段落汇总记录</strong>（字段布局不同）。直接按固定 26 字节往前走，到那里就错位了。</p>\n<p>解法是：扫描「4 字节时间戳（落在合理 unix 区间）+ 1 字节 dataType ≤ 8」的候选起点（并去除重叠），再用值域筛出真正的小节：<code>1 ≤ 距离 ≤ 100 且 30 ≤ swolf ≤ 400 且 40 ≤ pace ≤ 1200</code>。</p>\n<h3>顺便破了 SWOLF 的算法</h3>\n<pre><code>单趟用时(秒) = sectionPace × 池长 / 100\nSWOLF        = round(单趟用时) + 划水次数</code></pre>\n<p>逐字节验证：id=14 第 0 趟，<code>pace=216</code> × 25/100 = 54.0 秒，划水 14 次 → <strong>54 + 14 = 68</strong>，正好等于文件里存的 swolf。</p>\n<h3>验证结果（与官方报表精确吻合）</h3>\n<pre><code>id=14  44 小节  距离 1100/1100  转身 44/44  划水 602/602\n       swolf 均 66.4/66   最佳 51/51\n       pace  均 211/210   最快 158/158   最慢 316/316   划频 max 24/24\nid=5   44 小节  距离 1100/1100  转身 44/44  划水 617/617\n       swolf 均 71.1/71   最佳 54/54\n       pace  均 228/228   最快 164/164   最慢 452/452</code></pre>\n<p>池长从 <code>sectionDistance</code> 的众数推出来——因为官方报表里 <code>pool_width</code> 是 0，表示用户没设置过。</p>\n<h2>第七层：不编造数值</h2>\n<p>逆向数据最容易犯的错，是「猜一个看起来合理的值填进去」。我给自己定了两条硬规则：</p>\n<h3>规则一：文件里没有的字段，一律返回 None</h3>\n<p>而不是填 0。因为 0 在运动数据里是<strong>有意义的真实值</strong>（静止时速度就是 0），拿它表示「缺失」会让下游无法区分。</p>\n<p>具体到 UI：缺失的卡片直接隐藏，FIT 里不写零，页面明确标注「文件里没有此字段」。</p>\n<h3>规则二：列位置随类型变的字段，一律用官方值反查</h3>\n<p>心率列和卡路里列在不同运动类型里位置不同：</p>\n<pre><code>骑行     P=7 ：心率 b1 | 距离 b3÷1  | 速度 b4(u16×0.1 km/h)\n室内跑步 P=12：心率 b1 | 距离 b2÷10 | 步幅 b3(cm) | 步频 b9\n徒步     P=13：心率 b1 | 距离 b3÷10 | 步幅 b4(cm) | 步频 b10\n室内健身 P=4 ：心率 b0 | 卡路里 b1 整字节\n爬楼机   P=4 ：心率 b0 | 卡路里 b1 整字节\n椭圆机   P=3 ：心率 b1 | 卡路里 b0 高 4 位</code></pre>\n<p>这里踩过一个很典型的坑：<code>hr_col = detect(...) or 1</code>。<strong>列号 0 是合法值，但 <code>0 or 1 == 1</code></strong>，结果室内健身和爬楼机的心率被解成 1/1。</p>\n<p>另一个：卡路里探测写成「只有点长 ≥ 6 才读」，而爬楼机 <code>P=4</code>、椭圆机 <code>P=3</code>，结果是这些类型的卡路里<strong>永远是 0</strong>。改成用官方 <code>calories</code> 反查（逐点求和 ≈ 官方值 ±3%，优先取值多样的列），命中率：爬楼机 254/254、室内健身 7/7、椭圆机 242/242、室内跑步 323/323、徒步 1335/1335、骑行 95/96。</p>\n<h3>规则三：派生值不判对错，只说明</h3>\n<p>有些官方值是滤波/派生过的，和原始文件算出来的天然不同。比如「最大速度」：徒步原始 GPS 最大 17.64 km/h，官方只报 6.91；而骑行两者几乎一致（37.80/37.76、41.04/41.01、57.60/56.94，差 &lt;1.5%）。</p>\n<p>所以对照表里这类差异标<strong>蓝色「说明」而非红色叉号</strong>——对不上不代表解错了。</p>\n<h2>第八层：导出 FIT</h2>\n<p>一份完整的 FIT = GPS 文件 + record 文件，按时间戳合并。</p>\n<ul>\n<li><strong>同一秒可能有多个 GPS 点</strong>，必须用 <code>gps_by_t.setdefault(t, []).append(g)</code> 全部保留，否则会丢掉约 69 个点（746 → 677）。</li>\n<li>只有打点、没有 GPS 的秒也要单出一条，否则丢心率。</li>\n<li><code>distance</code> 必须<strong>按时间轴单次累加</strong>：先把 <code>newDistance</code> 按时间累加成 dict，GPS 独有的秒沿用上一个累计值。分段累加会算成 4201m（超过报表的 4088m）。</li>\n<li>FIT 的 <code>speed</code> 单位是 <strong>m/s</strong>；record 里是 km/h × 10，GPS 里是 m/s（÷10 后）——别混。</li>\n</ul>\n<h3>坑：FIT 纪元</h3>\n<p><strong>所有 timestamp 必须减 <code>631065600</code></strong>（FIT 纪元是 1989-12-31 UTC），否则解出来是 2046 年。这是最容易漏的一步。</p>\n<p>产出校验（883 records，CRC 全部无错）：</p>\n<pre><code>含GPS=746  含心率=866  距离末值=4088.0m  单调递增=True\n心率 均126.1/最大171   速度最大37.80 km/h\nsession: cycling | 4088.0m | 1001.0s | 95kcal | HR 127/171</code></pre>\n<h2>顺带解决：海拔补全</h2>\n<p>小米的 GPS 文件里<strong>不含海拔字段</strong>（实测导出都是 18 字节变体，只写到 speedAndSource），小米也没有「查任意坐标海拔」的接口。所以只能用第三方 DEM 按坐标补。</p>\n<p>这里有个值得记的教训：<strong>数据集选错会让累计爬升虚高好几倍。</strong></p>\n<p>我最初用 ASTER GDEM，一条 250km 的骑行算出累计爬升 <strong>2989m</strong>。但对比别人相似路线的 800m 左右，差距太大。查下来原因是：ASTER 在珠三角冲积平原会把 8m 的城区读成 43m（+35m 伪影），250km 里被累加了 537 次。</p>\n<p>换成 SRTM 后同一条路线是 <strong>1260m</strong>。地标真值也支持这个结论：顺德大良城区实际约 8m，ASTER 读 43m，SRTM/mapzen 读 14m；而皂幕山 805m，三套数据都给 796~798。</p>\n<p>算法上也做了修正：累计爬升用<strong>滞回阈值 + 按距离 100m 等间隔重采样</strong>，而不是直接把所有正增益加起来（DEM 的整米阶梯噪声会被全算成坡）。并且报告一个不确定度区间（T=5/T=12 两端）而非单一数字——250km 骑行的区间是 983~1744m。</p>\n<h2>踩过的其他坑（速查）</h2>\n<ul>\n<li><strong>服务端单次请求最多回 50 条，且静默截断。</strong>区间 7d → 10 条，90d → 46 条，365d → 50 条，730d → 还是 50 条。<strong>必须分块拉取</strong>：块内返回 ≥ 48 就把区间对半细分重拉。我一开始只拉 30 天，以为账号里就 30 条记录，实际有 416 条、覆盖 23 个月。</li>\n<li><strong><code>days = int(x) or 90</code> 是个陷阱。</strong>前端「全部」按钮传 <code>days=0</code>，被当成 falsy 静默变成 90 天。要显式判 <code>None</code>，再把 <code>&lt;=0</code> 映射成上限。</li>\n<li><strong>无 GPS 的运动类型要维护白名单。</strong>球类（台球/乒乓/羽毛球）、钓鱼、健美操、HIIT、腰部训练等，服务端不存 GPS，下载 404 是正常的。不列入白名单就会每次打开详情都报假警告。但<strong>攀岩有轨迹</strong>（实测 10982 点），不能一并排除。</li>\n<li><strong>DataPoint 数量 ≠ 时长。</strong>官方 <code>duration</code> 等于文件里的点数，但 <code>end_time - start_time</code> 包含暂停。</li>\n<li><strong>室内跑步没有速度列</strong>，配速必须由每秒距离推（<code>1000 / step_m</code>）；且全程配速要用<strong>全部秒数</strong>（即官方 <code>duration</code> 的语义），用「有位移的秒数」会偏快（6'01\" vs 官方 6'29\"）。</li>\n<li><strong>室内跑步的 b3 是步幅不是距离</strong>——当距离累加会得到 193075m（真实 5378m），错 36 倍。</li>\n</ul>\n<h2>验证方法论</h2>\n<p>回头看，整个逆向过程里最有价值的不是某个具体发现，而是这套验证习惯：</p>\n<ul>\n<li><strong>用 <code>signature</code> 当试金石。</strong>它对已知密文做哈希，所以只要猜中 <code>security</code> 就能立刻验证，不必等明文解对。比盲试解密快得多。</li>\n<li><strong>必须跨参数校验。</strong>单独解开 <code>data</code> 不代表算法完整，只有同时校验 <code>rc4_hash__</code> 才会暴露「共享 keystream」这类坑。</li>\n<li><strong>终极闭环是重新加密。</strong>用算法加密已知明文，与抓包原值三方逐字符比对——全对才算真通了。</li>\n<li><strong>官方报表永远是最好的标准答案。</strong>距离、时长、心率均值/最大、卡路里、速度——每一项都能当校验信号，比人工判断可靠得多。</li>\n<li><strong>解析失败要当 bug 查，不能当噪声忍。</strong><code>Incorrect padding</code>、<code>sectionDistance=65280</code> 这类异常，背后往往都是一个真实的结构性发现。</li>\n</ul>\n<h2>最终成果</h2>\n<p>现在这套东西是一个本地 Web 应用（Flask + 离线自包含前端，图表和地图都不依赖外网 CDN）：</p>\n<ul>\n<li><strong>记录列表</strong>：416 条 / 18 种运动类型，支持按类型筛选和分页</li>\n<li><strong>详情页</strong>：心率/速度/海拔/爬升曲线 + Leaflet 轨迹地图 + 各运动类型的专有面板（跑步的分段配速/步频/步幅散点，游泳的分段配速/划频/SWOLF）</li>\n<li><strong>数据对照表</strong>：解密值 vs 小米官方，逐项差异标注</li>\n<li><strong>FIT 导出</strong>：可直接导入 Garmin / Strava</li>\n<li><strong>离线韧性</strong>：凭证过期时仍可用缓存的历史数据</li>\n</ul>\n<p>整个解析链路的核心设计原则是<strong>自适应 + 用官方值校验</strong>，而不是写死任何字节数——因为同一个 App 导出的文件，布局本来就逐个活动不同。</p>\n<p>如果你也在做类似的事，希望这篇能帮你绕过那几个最深的坑。尤其是那个 RC4 跳过 1024 字节——它真的浪费了我很多时间。</p>\n<hr>\n<p><em>本文所述均为对自有账号数据的个人备份与分析，未涉及任何服务端的未授权访问。文中所有凭证类字段均已隐去。</em></p>\n","content_text":"起因\n\n我用小米手环记录运动已经快两年了。某天想把骑行数据导进 Strava 做路线分析，才发现问题：官方 App\n的导出能力相当有限，而云端其实存着远比界面展示更丰富的原始数据——逐秒心率、速度、踏频、GPS\n轨迹、甚至游泳每一趟的配速和划水次数，全都躺在服务器上，只是没有出口。\n\n于是我决定自己做这个出口。最终成果是一个本地 Web 应用 ~/mifit-web（Flask + 离线自包含前端），能直接对接小米运动健康后端，拉取 → 解密\n→ 解析 → 生成标准 .fit 文件，也能在网页上看完整的数据分析和轨迹地图。\n\n这篇文章记录整个逆向过程——包括那些让我卡了好几个小时的坑。如果你也在做类似的 App 数据自救，希望这些经验能省你一些时间。\n\n先说结论：这套流程目前跑通了 416 条运动记录，覆盖 2024-10 到 2026-09 共 23 个月、18\n种运动类型，解析出的时长、心率、卡路里与官方报表零条不匹配。\n\n\n第一层：抓包\n\n目标 App com.xiaomi.miwatch.pro，后端 hlth.io.mi.com。iOS 上我用 HTTP Catcher 抓包，产物是 .hcf\n文件。\n\n\n坑：一条请求被拆成多条记录\n\n解析 .hcf 时第一个意外是：HTTP 头在一条记录里，加密参数的表单却在紧随其后的另一个分片里。记录头格式是：\n\n00 00 0c 05  (请求) / 00 00 0c 08  (响应)\n+ reqseq(4) + ts(8, 毫秒) + paylen(4)\n\n\n坑：绑定顺序必须按「文件位置」，不能按「时间序」\n\n要把请求行和它的参数表单配对。我一开始按时间顺序配对，签名校验率只有 47%——因为相邻请求的表单会互相串位。\n\n改成按文件位置划窗口，再用「哪个候选的 data 能解出合法 JSON」来定绑，校验率立刻升到 79%。\n\n还有个很隐蔽的坑：提取 _nonce 的正则如果写成 _nonce=([^\\x00\\r\\n ]+)，会贪婪地吞掉后面整个查询串——实测 nonce 变成了\n287 个字符。结果是所有候选都不满足「nonce 长 12/16」而被丢弃，表现为「表单 0 个、请求 0 条」，可正则明明匹配到 21 处。正确写法必须排除\n&：\n\n_nonce=([^\\x00\\r\\n &]+)\n\n另外 token 有域的概念：抓包里会同时出现两个 serviceToken——hlth 域的约 172 字符，api.io.mi.com 的约 216\n字符。取错就一直是 auth err。\n\n\n第二层：签名算法\n\n小米这套接口的每个请求都带三个加密参数：data（业务数据密文）、rc4_hash__、signature。要自己构造请求，必须完全复现它的算法。反编译 APK\n后我一点点把逻辑拼了出来，其中最要命的一条是这个：\n\n\n致命坑：RC4 必须跳过前 1024 字节 KEYSTREAM\n\n看 esj 类的构造函数结尾有一句 b(f17415b)，而 f17415b 是一个 new byte[1024] 的全零数组——等于用 1024 个 0x00\n去预热 RC4 状态机。\n\n所以实际 keystream 是从第 1024 字节开始的。如果按标准 RC4 从 0 开始取，得到的是高熵垃圾，还会误判成「security\n不对」，一路往错误方向排查。这是整件事里最大的时间黑洞。\n\n\n另外三个容易搞错的点\n\n * t12.i() 是标准 Base64，不是 hexdigest。判据：signature 密文 28 字符（= 20 字节\n   SHA1），rc4_hash__ 密文 40 字符（= 28 字节）。如果你算出 40 字符的十六进制串，说明用错了 hexdigest()。\n * 一条请求内多个参数共享同一条 RC4 keystream（连续累加）。y54.c 只 new esj(strB) 一次，然后按 TreeMap\n   顺序切分：data 占 K[0:n]，rc4_hash__ 接着 K[n:]。只单独解 data 看不出问题，校验第二个参数时才会暴露。\n * rc4_hash__ 用明文 kv 算，signature 用密文 kv 算。两者都不包含 _nonce / signature /\n   rc4_hash__；而 data 自身不在签名 kv 里，是以字面量 data=<值> 的形式拼进签名串的。\n\n\n完整算法\n\nraw32 = SHA256( b64decode(security) || b64decode(_nonce) )   # 32 字节\nstrB  = base64encode(raw32)\n\n# y54.c —— GET/POST 通用（注意：不是 y54.d）\nrc4_hash__ = b64(SHA1( METHOD & path & \"k=v\"按key排序 & strB ))   # 明文 kv\nrc4 = RC4(raw32, skip=1024)                                    # 整条请求只建一次！\nfor k in sorted(treeMap):\n    out[k] = b64( rc4.xor(utf8(treeMap[k])) )\nsignature = b64(SHA1( METHOD & path & \"k=v\"按key排序 & strB ))   # 密文 out\nout[\"_nonce\"] = nonce\n\n_nonce 的构造是 base64( 8字节随机 + int32_be((now+timeDiff)//60000) )，共 16 字符——末 4\n字节是大端分钟数，正好可以用来核对抓包时间。\n\n还有个必须知道的事实：security 会随登录变化，必须用与目标请求同时段的那个。来源是响应头 extension-pragma 里的\nssecurity，或登录响应 JSON 的 data.ssecurity。\n\n\n第三层：三个把 401 变 200 的关键\n\n算法复现对了（重新加密能和抓包原值逐字符比对一致），但请求发出去仍然是 401。又踩了三个坑才通：\n\n\nA. 请求参数是「单个 DATA 字段」，值是整段 JSON 字符串\n\n# ✓ 对\nkv = {\"data\": '{\"watermark\":0,\"limit\":30}'}\n# ✗ 错（会一直 401！）—— 把 JSON 字段拆成独立参数\nkv = {\"watermark\": 0, \"limit\": 30}\n\n拆开算签名，服务端一律返回 auth err。验证方法：用已知明文重新加密，和抓包原值逐字符比对，三个字段必须全对。\n\n\nB. @SECRET(PATHPREFIX=\"HEALTHAPP/\") → 签名要用裁剪后的 SUBPATH\n\n规则在 CloudInterceptor.subpath：如果 pathPrefix 非空，就取 URL 中该子串出现位置之后的部分；为空则从第一个 / 起取。\n\nURL   /healthapp/service/gen_download_url\n签名  /service/gen_download_url        ← 用这个算 signature / rc4_hash__\n\n同理 /healthapp/home/profiles → /home/profiles。而 /app/v1/*、/ops/v1/* 的 pathPrefix\n为空，用完整路径。这一条解开后，401 立刻变 200。\n\n\nC. 响应解密用「请求同一个 NONCE」，且有三种形态\n\n要按顺序试：① 明文 JSON（不加密的端点）；② gzip 包着的 base64 密文（chunked 响应）；③ 直接就是 base64 密文（非\nchunked 响应，实时 API 常见）。\n\n\n第四层：原始文件的加密\n\n拿到接口还不够——真正的原始数据（逐秒心率、GPS 轨迹）是通过一个文件下载接口拿的，返回的是加密文件。我一度以为解不开，后来发现密钥就明晃晃地放在\ngen_download_url 的响应里：\n\n{\"result\": {\"<suffix>_<ts>\": {\n   \"url\": \"https://cnbj1.fds.api.xiaomi.com/...\",\n   \"obj_key\": \"[REDACTED]\",   ← AES 密钥在这里\n   \"method\": \"GET\"}}}\n\n反编译确认了 AESCoder(String aesKey) { this(Base64.decode(aesKey, 8)); }，于是：\n\nkey  = base64.urlsafe_b64decode(obj_key + '=' * (-len(obj_key) % 4))   # 16 字节\nIV   = b'1234567887654321'          # AESCoder.getInitialVector() 硬编码\nalgo = AES/CBC/PKCS5Padding\n\nct  = base64.urlsafe_b64decode(txt + '=' * (-len(txt) % 4))\nraw = AES_CBC_decrypt(key, IV, ct)  然后 PKCS5 unpad\n\n每份文件的 obj_key 各不相同（GPS 文件和打点文件的密钥不一样），必须各自取用，不能复用。\n\n\n坑：CDN 返回体有两种形态\n\n① 裸 base64url，'=' padding 被剥掉        b'iLgArCVs8Jn...c'\n② JSON 字符串（带双引号），同样无 padding  b'\"4feFfXCJLHKw...q\"'\n\n只 .strip() 去不掉引号。而更阴的是——引号恰好让长度凑成了 4 的倍数，于是代码跳过了补 padding 的逻辑，解码时长度又少 2 → 抛\nIncorrect padding。\n\n症状很有意思：一部分活动（乒乓球、腰部训练、钓鱼……）详情页直接报错，另一部分完全正常。原因是这两类活动走的分支不同。修法是先剥引号、再按真实长度补 =。\n\n\n第五层：二进制格式解析\n\n解密后得到的是 schema 驱动的二进制。解析器在 APK 里是 com/xiaomi/fit/fitness/parser/schema/ 那一套。以\nGPS 文件为例，头部 9 字节：\n\n[0:4] timestamp u32LE   [4] timezone u8   [5] version u8\n[6]   fileType   bitField: normalDataFlag(7) dataType(6..2) fileType(1..0)\n[7]   encComp    bitField: encryptionMode(7..4) compressionMode(3..0)\n[8]   validity   bitField: time(7) lon(6) lat(5) acc(4) speed(3) src(2) alt(1) hdop(0)\n\n点字段必须按 schema 的固定偏移，不能顺次臆测：\n\n[0:4]   time                          u32LE\n[4:8]   longitude                     f32LE\n[8:12]  latitude                      f32LE\n[12:16] locationHorizontalAccuracy    f32LE   ← 水平精度，不是海拔！\n[16:18] locationSpeedAndGpsSource     u16LE   speed=(v>>4)/10 m/s, src=v&0x0F\n[18:22] altitude                      f32LE\n[22:26] hdop                          f32LE\n\n我一开始把 [12:16] 当海拔，结果拿到的是水平精度——平坦路恒为 4.75m，定位野值点跳到\n1448，看着像条曲线，其实毫无物理意义。这类「看着合理但完全错误」的 bug 最难发现。\n\n\n核心认知：结构是变的，绝不写死字节数\n\n同一个 App 导出的文件，因为 validityFlags 决定哪些字段被写入，布局逐个活动都不同：\n\n骑行徒步 GPS 点长18 B18 B record 头部11 B13 B record 点长7 B13 B 速度在哪record 里 (u16LE@4,\n×0.1)record 里没有，只有 GPS 有 里程单位米 (÷1)0.1 米 (÷10)\n\n所以解析器只能自适应探测，而关键是——用什么当校验信号？答案是官方报表。报表里的 distance / duration / avg_hrm / max_hrm\n/ calories 就是标准答案：\n\n * GPS：枚举点长 P，要求 body % P == 0，且时间戳单调不减、坐标聚在一小片区域、速度物理合理。\n * record：枚举 (头部长度, 段头长度, 段头内偏移, 点长)，要求段链恰好走到 EOF，再加「里程合计 ≈ 报表 distance」「点数 ≈ 报表\n   duration」「心率列均值/最大 ≈ 报表」三重打分。\n\n\n坑：段链定位不能靠「第一个像时间戳的值」\n\nrecord 文件是多个段拼接的，结构是「段头 + N 个点」循环直到 EOF。段头里 resumeTimestamp 是段的起始时间。\n\n如果从 offset 0 开始扫「第一个像时间戳的 u32」，会命中段头里由掩码/里程高位拼出的伪值（实测 0x739e0000），于是把 814 秒的骑行算成\n65011721 秒。\n\n阴险的地方在于：段链本身没坏，段数/点数/里程全对，只有时长崩了，极易漏掉。正确判据要三个同时成立：\n\nvals = [u32le(head, off) for each seg head]\nall(MIN_UNIX < v < MAX_UNIX for v in vals)      # 全部落在合理 unix 区间\nall(b >= a for a, b in zip(vals, vals[1:]))      # 跨段非递减\nall(vals[i+1]-vals[i] >= rcs[i]*0.95)            # 后段起点 ≥ 前段起点 + 前段点数\n\n骑行与徒步实测都落在 offset 8。\n\n\n坑：DURATION 和 ELAPSED 是两回事\n\n指标含义实测对齐 报表 duration实际运动秒数（不含暂停）= 文件点数徒步 12745 = 12745 ✓ end_time -\nstart_time墙钟跨度（含暂停）骑行 1607 s ✓\n\n拿 end_time - start_time 去校验 duration 会误判成 bug（骑行 1001 vs 814，看着差 23%），其实两个都对。FIT\n里分别对应 total_timer_time 和 total_elapsed_time。\n\n\n第六层：游泳分段指标的破解\n\n这是最近才攻下的部分，也是最花时间的一块。\n\n小米 App 在游泳详情里会画出「分段配速」「分段划频」「分段 SWOLF」三张图。我想知道这些是本地算的还是服务端给的。反编译定位到\nSportRecordConverter.java，它构造 SwimmingPassage 对象时读的是 oneSportRecord.getPace() /\ngetSwolf()——来自服务端下发的 record 字段，不是本地计算的。wire 级字段名是 sectionPace / sectionSwolf /\nsectionStrokeFrequency。\n\n但把 get_sport_records_by_time 的完整响应翻遍，value\n里只有汇总字段：avg_pace、max_pace、avg_swolf、best_swolf、stroke_count、turn_count……没有任何\nsection 数组。\n\n关键线索来自 schema 文件 swimming_record_v3.json——它的 dataPoint 里明确声明了 sectionPace /\nsectionSwolf / sectionStrokeFrequency，而且字节大小是写明的。\n\n\n为什么按 SCHEMA 硬套永远对不上\n\nAPK 里那个 schema 声明的是 version 8.0，dataPoint 约 35 字节。而实际文件是 version 2、每小节 26\n字节——字段增删过。按 schema 硬套只会一路错位，表现为 sectionDistance 读出 65280 这种荒谬值。\n\n\n突破：放弃固定布局，改用「扫时间戳 + 值域筛选」重新同步\n\n实测出来的 26 字节布局：\n\n+0   endTime                  u32   本节结束时间\n+4   dataType                 u8    0=小节 / 1、2=段落汇总\n+5   sectionPace              u16   秒/百米       ★ 分段配速\n+7   sectionSwolf             u16   SWOLF         ★ 分段 SWOLF\n+9   sectionDistance          u16   本小节距离(米，=池长)\n+11  activeCalories           u16\n+13  totalArmPull             u16   本小节划水次数\n+15  turnCount                u16   本小节转身次数\n+17  sectionStrokeFrequency   u8    本小节划频     ★ 分段划频\n+18  unknownStrokeFreq        u8\n+19  breastStrokeFreq         u8\n+20  freeStrokeFreq           u8\n+21  backStrokeFreq           u8\n+22  butterflyStrokeFreq      u8\n+23  totalCalories            u16\n+25  标志位                   u8\n\n真正的障碍是：每约 4 个小节后跟一条变长的段落汇总记录（字段布局不同）。直接按固定 26 字节往前走，到那里就错位了。\n\n解法是：扫描「4 字节时间戳（落在合理 unix 区间）+ 1 字节 dataType ≤ 8」的候选起点（并去除重叠），再用值域筛出真正的小节：1 ≤ 距离\n≤ 100 且 30 ≤ swolf ≤ 400 且 40 ≤ pace ≤ 1200。\n\n\n顺便破了 SWOLF 的算法\n\n单趟用时(秒) = sectionPace × 池长 / 100\nSWOLF        = round(单趟用时) + 划水次数\n\n逐字节验证：id=14 第 0 趟，pace=216 × 25/100 = 54.0 秒，划水 14 次 → 54 + 14 = 68，正好等于文件里存的\nswolf。\n\n\n验证结果（与官方报表精确吻合）\n\nid=14  44 小节  距离 1100/1100  转身 44/44  划水 602/602\n       swolf 均 66.4/66   最佳 51/51\n       pace  均 211/210   最快 158/158   最慢 316/316   划频 max 24/24\nid=5   44 小节  距离 1100/1100  转身 44/44  划水 617/617\n       swolf 均 71.1/71   最佳 54/54\n       pace  均 228/228   最快 164/164   最慢 452/452\n\n池长从 sectionDistance 的众数推出来——因为官方报表里 pool_width 是 0，表示用户没设置过。\n\n\n第七层：不编造数值\n\n逆向数据最容易犯的错，是「猜一个看起来合理的值填进去」。我给自己定了两条硬规则：\n\n\n规则一：文件里没有的字段，一律返回 NONE\n\n而不是填 0。因为 0 在运动数据里是有意义的真实值（静止时速度就是 0），拿它表示「缺失」会让下游无法区分。\n\n具体到 UI：缺失的卡片直接隐藏，FIT 里不写零，页面明确标注「文件里没有此字段」。\n\n\n规则二：列位置随类型变的字段，一律用官方值反查\n\n心率列和卡路里列在不同运动类型里位置不同：\n\n骑行     P=7 ：心率 b1 | 距离 b3÷1  | 速度 b4(u16×0.1 km/h)\n室内跑步 P=12：心率 b1 | 距离 b2÷10 | 步幅 b3(cm) | 步频 b9\n徒步     P=13：心率 b1 | 距离 b3÷10 | 步幅 b4(cm) | 步频 b10\n室内健身 P=4 ：心率 b0 | 卡路里 b1 整字节\n爬楼机   P=4 ：心率 b0 | 卡路里 b1 整字节\n椭圆机   P=3 ：心率 b1 | 卡路里 b0 高 4 位\n\n这里踩过一个很典型的坑：hr_col = detect(...) or 1。列号 0 是合法值，但 0 or 1 == 1，结果室内健身和爬楼机的心率被解成\n1/1。\n\n另一个：卡路里探测写成「只有点长 ≥ 6 才读」，而爬楼机 P=4、椭圆机 P=3，结果是这些类型的卡路里永远是 0。改成用官方 calories\n反查（逐点求和 ≈ 官方值 ±3%，优先取值多样的列），命中率：爬楼机 254/254、室内健身 7/7、椭圆机 242/242、室内跑步 323/323、徒步\n1335/1335、骑行 95/96。\n\n\n规则三：派生值不判对错，只说明\n\n有些官方值是滤波/派生过的，和原始文件算出来的天然不同。比如「最大速度」：徒步原始 GPS 最大 17.64 km/h，官方只报\n6.91；而骑行两者几乎一致（37.80/37.76、41.04/41.01、57.60/56.94，差 <1.5%）。\n\n所以对照表里这类差异标蓝色「说明」而非红色叉号——对不上不代表解错了。\n\n\n第八层：导出 FIT\n\n一份完整的 FIT = GPS 文件 + record 文件，按时间戳合并。\n\n * 同一秒可能有多个 GPS 点，必须用 gps_by_t.setdefault(t, []).append(g) 全部保留，否则会丢掉约 69 个点（746\n   → 677）。\n * 只有打点、没有 GPS 的秒也要单出一条，否则丢心率。\n * distance 必须按时间轴单次累加：先把 newDistance 按时间累加成 dict，GPS 独有的秒沿用上一个累计值。分段累加会算成\n   4201m（超过报表的 4088m）。\n * FIT 的 speed 单位是 m/s；record 里是 km/h × 10，GPS 里是 m/s（÷10 后）——别混。\n\n\n坑：FIT 纪元\n\n所有 timestamp 必须减 631065600（FIT 纪元是 1989-12-31 UTC），否则解出来是 2046 年。这是最容易漏的一步。\n\n产出校验（883 records，CRC 全部无错）：\n\n含GPS=746  含心率=866  距离末值=4088.0m  单调递增=True\n心率 均126.1/最大171   速度最大37.80 km/h\nsession: cycling | 4088.0m | 1001.0s | 95kcal | HR 127/171\n\n\n顺带解决：海拔补全\n\n小米的 GPS 文件里不含海拔字段（实测导出都是 18 字节变体，只写到 speedAndSource），小米也没有「查任意坐标海拔」的接口。所以只能用第三方\nDEM 按坐标补。\n\n这里有个值得记的教训：数据集选错会让累计爬升虚高好几倍。\n\n我最初用 ASTER GDEM，一条 250km 的骑行算出累计爬升 2989m。但对比别人相似路线的 800m 左右，差距太大。查下来原因是：ASTER\n在珠三角冲积平原会把 8m 的城区读成 43m（+35m 伪影），250km 里被累加了 537 次。\n\n换成 SRTM 后同一条路线是 1260m。地标真值也支持这个结论：顺德大良城区实际约 8m，ASTER 读 43m，SRTM/mapzen 读\n14m；而皂幕山 805m，三套数据都给 796~798。\n\n算法上也做了修正：累计爬升用滞回阈值 + 按距离 100m 等间隔重采样，而不是直接把所有正增益加起来（DEM\n的整米阶梯噪声会被全算成坡）。并且报告一个不确定度区间（T=5/T=12 两端）而非单一数字——250km 骑行的区间是 983~1744m。\n\n\n踩过的其他坑（速查）\n\n * 服务端单次请求最多回 50 条，且静默截断。区间 7d → 10 条，90d → 46 条，365d → 50 条，730d → 还是 50\n   条。必须分块拉取：块内返回 ≥ 48 就把区间对半细分重拉。我一开始只拉 30 天，以为账号里就 30 条记录，实际有 416 条、覆盖 23 个月。\n * days = int(x) or 90 是个陷阱。前端「全部」按钮传 days=0，被当成 falsy 静默变成 90 天。要显式判 None，再把\n   <=0 映射成上限。\n * 无 GPS 的运动类型要维护白名单。球类（台球/乒乓/羽毛球）、钓鱼、健美操、HIIT、腰部训练等，服务端不存 GPS，下载 404\n   是正常的。不列入白名单就会每次打开详情都报假警告。但攀岩有轨迹（实测 10982 点），不能一并排除。\n * DataPoint 数量 ≠ 时长。官方 duration 等于文件里的点数，但 end_time - start_time 包含暂停。\n * 室内跑步没有速度列，配速必须由每秒距离推（1000 / step_m）；且全程配速要用全部秒数（即官方 duration\n   的语义），用「有位移的秒数」会偏快（6'01\" vs 官方 6'29\"）。\n * 室内跑步的 b3 是步幅不是距离——当距离累加会得到 193075m（真实 5378m），错 36 倍。\n\n\n验证方法论\n\n回头看，整个逆向过程里最有价值的不是某个具体发现，而是这套验证习惯：\n\n * 用 signature 当试金石。它对已知密文做哈希，所以只要猜中 security 就能立刻验证，不必等明文解对。比盲试解密快得多。\n * 必须跨参数校验。单独解开 data 不代表算法完整，只有同时校验 rc4_hash__ 才会暴露「共享 keystream」这类坑。\n * 终极闭环是重新加密。用算法加密已知明文，与抓包原值三方逐字符比对——全对才算真通了。\n * 官方报表永远是最好的标准答案。距离、时长、心率均值/最大、卡路里、速度——每一项都能当校验信号，比人工判断可靠得多。\n * 解析失败要当 bug 查，不能当噪声忍。Incorrect padding、sectionDistance=65280\n   这类异常，背后往往都是一个真实的结构性发现。\n\n\n最终成果\n\n现在这套东西是一个本地 Web 应用（Flask + 离线自包含前端，图表和地图都不依赖外网 CDN）：\n\n * 记录列表：416 条 / 18 种运动类型，支持按类型筛选和分页\n * 详情页：心率/速度/海拔/爬升曲线 + Leaflet 轨迹地图 +\n   各运动类型的专有面板（跑步的分段配速/步频/步幅散点，游泳的分段配速/划频/SWOLF）\n * 数据对照表：解密值 vs 小米官方，逐项差异标注\n * FIT 导出：可直接导入 Garmin / Strava\n * 离线韧性：凭证过期时仍可用缓存的历史数据\n\n整个解析链路的核心设计原则是自适应 + 用官方值校验，而不是写死任何字节数——因为同一个 App 导出的文件，布局本来就逐个活动不同。\n\n如果你也在做类似的事，希望这篇能帮你绕过那几个最深的坑。尤其是那个 RC4 跳过 1024 字节——它真的浪费了我很多时间。\n\n--------------------------------------------------------------------------------\n\n本文所述均为对自有账号数据的个人备份与分析，未涉及任何服务端的未授权访问。文中所有凭证类字段均已隐去。","date_published":"2026-09-19T03:05:19.632Z","_microfeed":{"web_url":"https://blog-madeai-eu-org.pages.dev/i/fit-BJndgcYhWBB/","json_url":"https://blog-madeai-eu-org.pages.dev/i/BJndgcYhWBB/json/","rss_url":"https://blog-madeai-eu-org.pages.dev/i/BJndgcYhWBB/rss/","guid":"BJndgcYhWBB","status":"published","date_published_short":"Fri Sep 18 2026","date_published_ms":1789787119632}}],"_microfeed":{"microfeed_version":"0.1.5","base_url":"https://blog-madeai-eu-org.pages.dev","categories":[{"name":"Business","categories":[{"name":"Investing"}]},{"name":"Arts","categories":[{"name":"Design"}]},{"name":"News","categories":[{"name":"Tech News"}]}],"subscribe_methods":[{"name":"RSS","type":"rss","url":"https://blog-madeai-eu-org.pages.dev/rss/","image":"https://blog-madeai-eu-org.pages.dev/assets/brands/subscribe/rss.png","enabled":true,"editable":false,"id":"U_92Aa3J4lP"},{"name":"JSON","type":"json","url":"https://blog-madeai-eu-org.pages.dev/json/","image":"https://blog-madeai-eu-org.pages.dev/assets/brands/subscribe/json.png","enabled":true,"editable":false,"id":"lNFg5bP2iTY"},{"name":"Github","type":"custom","url":"https://github.com/huangyebiaoke","image":"https://blog-madeai-eu-org.pages.dev/assets/brands/subscribe/custom.png","enabled":true,"editable":true,"id":"GacI4v1fpUQ"}],"description_text":"分享日常工作生活中的所思所想，拒绝碎片化信息，通过阅读和博客沉淀深度思考。","copyright":"©2026","itunes:type":"episodic","itunes:email":"huangyebiaoke@outlook.com","items_sort_order":"newest_first"}}