iT邦幫忙

2026 iThome 鐵人賽

DAY 6
0
Modern Web

教練看不到的那六天|從 FIT 檔到 3D 軌跡,馬拉松訓練資料 Dashboard系列 第 6

Day 6|Web Bluetooth 讀體重機:底層看得到,但 API 呢?

  • 分享至 

  • xImage
  •  

前五天的內容比較多都是和 Garmin 資料有關。今天要來換一個完全不同性質的資料來源—家中的小米體重機。

而這次不只是拿到資料,還多思考了一個問題:這件事能不能在瀏覽器裡做?

答案有點意外:是可以的,但有點曲折,現在就來看看怎麼進行瀏覽器能力邊界的嘗試。


先講這台體重機的特點:它並沒有管你有沒有在聽

官方的使用流程是:站上去 → 開 Zepp app → 資料上傳→ 回 app 查看。

但實際觀察會發現一件有趣的事:體重機不在乎有沒有人連上它。只要有人站上去,它就開始用藍牙對外「廣播」—把量到的數字裝成封包,一直往空氣中送,不需要配對或對方同意、也不管有沒有人接收。

這是藍牙 BLE 的兩種用法之一:

  • 連線(GATT):像連藍牙耳機那樣,先配對、建立一對一通道,之後才能交換資料,Zepp app 就是走這條。
  • 廣播(advertising):裝置單方面對外送,任何在範圍內、正在掃描的裝置都收得到。體重機站上去那一刻走的是這條。

所以其實每天早上測量時,它都在空氣中免費放送資料,只是平常沒人在接收。Python 那端要做的事很單純:用 bleak 開一個掃描器,過濾出 service data 帶著 0x181B 的廣播,就能收到了。

這裡有個時序上的小 tips:必須先開始監聽,再站上去。體重機只在有人站上面時廣播,如果先站上去才啟動程式,那段廣播早已結束,只能聽到一片寂寞🥹


那麼,瀏覽器可以聽嗎?

Python 之前已經試過可行,但既然這個系列最後要做的是一個網頁,很自然會思考:能不能讓瀏覽器直接拿到資料?

如果可以,使用情境會很好—打開網頁按個鈕然後站上去,數字當場出現在畫面上,不用開終端機也不用跑腳本。

瀏覽器確實有對應的 API:Web Bluetooth。所以直覺的做法是把 Python 那套邏輯照搬—既然是聽廣播,那就在瀏覽器裡也聽一下^0^

但嘗試後這條路卡住了。


卡住的地方:瀏覽器聽不到廣播

Web Bluetooth 裡負責掃描廣播的是 navigator.bluetooth.requestLEScan()。它存在、規格也寫好了,但到 2026 年的今天,它仍然是實驗性功能—使用者得自己去 chrome://flags/#enable-experimental-web-platform-features 打開,網頁才用得了。

https://ithelp.ithome.com.tw/upload/images/20260822/20183598osoiq0ExKk.png

到這裡本來想說實驗性功能應該不穩定,但既然是自己的專案,那就開來試試看。

第一層:開了功能,權限也給了

開啟旗標、重開瀏覽器,requestLEScan 這個方法確實出現了。按下掃描鍵,Chrome 跳出「是否允許這個網站掃描藍牙裝置」的授權框,按下允許之後,網址列旁邊也出現了「掃描中」的指示。

看起來一切正常。

https://ithelp.ithome.com.tw/upload/images/20260822/20183598pFeXxOIrED.png

第二層:但那個 Promise 永遠不會回來

問題是—await requestLEScan() 之後的那一行程式碼,從來沒有被執行過

log 停在「要求掃描權限…」,後面應該印出的「掃描已啟動」一行都沒有。站上體重機,等了十秒,收到的封包數是 0。不是收到壞掉的資料,是連一個事件都沒有進來。

查了才知道這是 Chromium 上已知的問題:權限對話框會讓頁面失去焦點,而失焦會讓掃描掛住,promise 不 resolve、advertisementreceived 也不觸發。社群回報的唯一繞法,是在 DevTools 裡對那一行下中斷點、step out 之後再按允許—需要靠 debugger 才能繞過。

(試了第二次,這回權限已經授權過、沒再跳對話框,理論上不會失焦—結果一樣掛住。所以失焦可能只是原因之一。)

第三層:但瀏覽器底層明明看得到

最後做了一個決定性的測試。Chrome 有一個內建的診斷頁 chrome://bluetooth-internals,可以直接觀察瀏覽器藍牙層看到了什麼。

打開它、開始掃描、站上體重機—MIBFS 出現在清單上,那正是這台米家體重機。

https://ithelp.ithome.com.tw/upload/images/20260822/2018359832I4iGLU9Z.png

所以在同台電腦使用 Chrome 來接受體重機的資料:

診斷頁面:看得到 MIBFS 正在廣播 ✓
requestLEScan:promise 不 resolve,零封包 ✗

Chrome 的藍牙底層完全正常、作業系統的權限也沒問題(GATT 確實能行)、體重機確實在對外廣播。資料就在那裡,只是 Web Bluetooth 的掃描 API 沒有把它交出來。

所以結論是 LE Scan 失敗 QQ

原本以為的結論是:這個能力被瀏覽器限制了,因為讓網頁被動掃描周遭裝置很敏感—那等於任何網頁都能知道你身邊有什麼設備。

但實測之後,結論更直接:就算你願意改設定、願意承擔那個隱私風險,目前還是無法輕易使用。


繞道:改用連線,拿到同一份資料

廣播這條路行不通,但還有另一條—GATT 連線

https://ithelp.ithome.com.tw/upload/images/20260822/20183598MZKE3t6Smk.png

前面提過體重機同時支援兩種模式。它除了廣播之外,也提供 GATT service 0x181B,底下有一個 characteristic 0x2A9C(Body Composition Measurement,支援 notify)。連上去訂閱這個 characteristic,體重機一樣會把資料推過來。

而且推過來的,是同一份 13 bytes

const device = await navigator.bluetooth.requestDevice({
  filters: [{ services: [0x181b] }],
});
const server  = await device.gatt.connect();
const service = await server.getPrimaryService(0x181b);
const ch      = await service.getCharacteristic(0x2a9c);

ch.addEventListener('characteristicvaluechanged', (e) => {
  const r = parse(e.target.value);   // 跟 Python 端同一套解析
  ...
});
await ch.startNotifications();

最後那行 startNotifications() 之後,才能站上去—順序陷阱在瀏覽器這邊一樣存在


那 13 bytes 裡面裝什麼

不管走哪條路,拿到的都是同一包 13 個位元組,拆法如下:

位元組 內容
0 單位旗標:判斷這個數字是公斤還是磅數
1 狀態位元:有沒有量到阻抗、數值是否穩定
9–10 生物阻抗(小端序)
11–12 體重原始值(小端序,要除以多少由 byte 0 決定

這張表裡有兩個地方會讓人誤會的地方

一:小端序,順序是反過來的

體重要用兩個位元組才裝得下,那這兩個誰先誰後?藍牙用的是小端序—低位在前,跟我們平常寫數字的習慣是相反的。

體重 73.15 公斤在封包裡是這樣:

73.15 kg → 整數 14630 → 十六進位 0x3926

小端序(正確):26 39 ← 低位在前
大端序(錯誤):39 26

如果讀成大端序,26 39 會被當成 0x2639 = 9785,換算後是 48.92 公斤

二:除數不是固定的

「除以多少」由單位旗標決定:

if ctrl0 & 0x01:                                  # 磅
    weight, unit = raw_w / 100, "lb"
elif ctrl0 & 0x10:                                # 台斤
    weight, unit = raw_w / 100, "jin"
else:                                             # 公斤
    weight, unit = raw_w / 200, "kg"

200 這個數字是怎麼來的? 因為體重機不存小數、只存整數,要保住精度就得先乘一個倍率。公斤的解析度是 0.005 公斤(也就是 5 公克),所以 1 ÷ 0.005 = 200

73.15 kg → 存成 14630 (14630 × 0.005 = 73.15)
72.45 kg → 存成 14490 (14490 × 0.005 = 72.45)

磅的解析度是 0.01 磅,所以除數是 100。這是藍牙標準裡 Body Composition Measurement 的規格定義,不是小米自己訂的。

那如果偷懶寫死成「一律除以 200」就會出問題,假設體重機切到磅制:

實際體重 73.15 kg = 161.27 磅
體重機存:raw = 16127

正確(÷100):161.27 磅 ✓
寫死(÷200): 80.64 ✗

80.64 確實剛好是一半。但真正麻煩的是—如果這個數字被當成公斤記進紀錄,而真實體重是 73.15 公斤,兩者只差七公斤多。

這兩個問題的共通點

48.92、80.64—這兩個錯誤數字有同一個特性:它們看起來都像是某個人的體重

程式不會報錯,畫面也出得來,但自己可能連續量了好幾週,只覺得「體重怎麼怪怪的」,卻完全不會聯想到是位元組順序或除數寫錯。

實際解出來會長這樣:

日期時間 體重 (kg) 阻抗 (Ω)
2026-07-21 00:17 73.15 446

阻抗那個數字現在還沒用上,但先存著—它是之後推算體脂率、肌肉量這些體組成數據的材料。


兩條路,兩種用途

繞道成功了,但還是不太方便:

瀏覽器版的限制—必須由使用者按下按鈕才能觸發(不能自動連線)、頁面必須跑在 https 或 localhost、Safari 和 Firefox 全平台都不支援 Web Bluetooth,只有 Chrome 和 Edge 能用。

換句話說,瀏覽器版沒辦法做無人值守的自動化。它永遠需要一個人在那裡按按鈕。

所以最後的分工很自然地落定了:

  • Python 那條:負責排程、自動化、無人值守,資料默默進系統
  • 瀏覽器那條:負責「現在想量一下,順便當場看到數字」

同一台裝置、同一份 13 bytes,兩條路徑各自服務不同的情境,希望 Chrome 之後能修好 api 的 bug 了。


明天:換一種完全不同的難題

體重機的資料算是乾淨清楚的—位置固定、格式明確,雖然有幾個要注意的細節,但整個解析函式很好處理。明天要處理的東西天差地遠:一套自己發明的、只有本人看得懂的重訓速記語法。那才是比較難搞的資料^^。


上一篇
Day 5|開箱 FIT 檔(下):解析一場全馬,還原每秒心率與配速
系列文
教練看不到的那六天|從 FIT 檔到 3D 軌跡,馬拉松訓練資料 Dashboard6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言