前五天的內容比較多都是和 Garmin 資料有關。今天要來換一個完全不同性質的資料來源—家中的小米體重機。
而這次不只是拿到資料,還多思考了一個問題:這件事能不能在瀏覽器裡做?
答案有點意外:是可以的,但有點曲折,現在就來看看怎麼進行瀏覽器能力邊界的嘗試。
官方的使用流程是:站上去 → 開 Zepp app → 資料上傳→ 回 app 查看。
但實際觀察會發現一件有趣的事:體重機不在乎有沒有人連上它。只要有人站上去,它就開始用藍牙對外「廣播」—把量到的數字裝成封包,一直往空氣中送,不需要配對或對方同意、也不管有沒有人接收。
這是藍牙 BLE 的兩種用法之一:
所以其實每天早上測量時,它都在空氣中免費放送資料,只是平常沒人在接收。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 打開,網頁才用得了。

到這裡本來想說實驗性功能應該不穩定,但既然是自己的專案,那就開來試試看。
開啟旗標、重開瀏覽器,requestLEScan 這個方法確實出現了。按下掃描鍵,Chrome 跳出「是否允許這個網站掃描藍牙裝置」的授權框,按下允許之後,網址列旁邊也出現了「掃描中」的指示。
看起來一切正常。

問題是—await requestLEScan() 之後的那一行程式碼,從來沒有被執行過。
log 停在「要求掃描權限…」,後面應該印出的「掃描已啟動」一行都沒有。站上體重機,等了十秒,收到的封包數是 0。不是收到壞掉的資料,是連一個事件都沒有進來。
查了才知道這是 Chromium 上已知的問題:權限對話框會讓頁面失去焦點,而失焦會讓掃描掛住,promise 不 resolve、advertisementreceived 也不觸發。社群回報的唯一繞法,是在 DevTools 裡對那一行下中斷點、step out 之後再按允許—需要靠 debugger 才能繞過。
(試了第二次,這回權限已經授權過、沒再跳對話框,理論上不會失焦—結果一樣掛住。所以失焦可能只是原因之一。)
最後做了一個決定性的測試。Chrome 有一個內建的診斷頁 chrome://bluetooth-internals,可以直接觀察瀏覽器藍牙層看到了什麼。
打開它、開始掃描、站上體重機—MIBFS 出現在清單上,那正是這台米家體重機。

所以在同台電腦使用 Chrome 來接受體重機的資料:
診斷頁面:看得到 MIBFS 正在廣播 ✓
requestLEScan:promise 不 resolve,零封包 ✗
Chrome 的藍牙底層完全正常、作業系統的權限也沒問題(GATT 確實能行)、體重機確實在對外廣播。資料就在那裡,只是 Web Bluetooth 的掃描 API 沒有把它交出來。
原本以為的結論是:這個能力被瀏覽器限制了,因為讓網頁被動掃描周遭裝置很敏感—那等於任何網頁都能知道你身邊有什麼設備。
但實測之後,結論更直接:就算你願意改設定、願意承擔那個隱私風險,目前還是無法輕易使用。
廣播這條路行不通,但還有另一條—GATT 連線。

前面提過體重機同時支援兩種模式。它除了廣播之外,也提供 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 個位元組,拆法如下:
| 位元組 | 內容 |
|---|---|
| 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 能用。
換句話說,瀏覽器版沒辦法做無人值守的自動化。它永遠需要一個人在那裡按按鈕。
所以最後的分工很自然地落定了:
同一台裝置、同一份 13 bytes,兩條路徑各自服務不同的情境,希望 Chrome 之後能修好 api 的 bug 了。
體重機的資料算是乾淨清楚的—位置固定、格式明確,雖然有幾個要注意的細節,但整個解析函式很好處理。明天要處理的東西天差地遠:一套自己發明的、只有本人看得懂的重訓速記語法。那才是比較難搞的資料^^。