在過去幾天,我們完成了宏偉的探索任務。我們的應用程式已經能夠繪製出任何藍牙裝置的完整「藏寶圖」,UI 上動態生成的服務與特徵面板,就是我們辛勤工作的成果。所有的寶箱(特徵)都已陳列在我們面前,並且我們清楚地知道每一個寶箱上掛著的是「讀取鎖」、「寫入鎖」還是「通知鎖」。
但是,寶箱本身不是寶藏,箱子裡的內容才是。
今天,我們將學習如何打開這些寶箱。我們將從最直接、最常見的操作——讀取 (Read)——開始。我們要為 UI 上那些 Read 按鈕注入真正的靈魂,讓它不再只是一個在控制台自言自語的 console.log,而是一個能真正從裝置中取回數據的「開鎖匠」。
今天的目標是:點擊「Read」按鈕,透過 characteristic.readValue() 從裝置獲取原始數據,解析它,並將結果光榮地顯示在我們的網頁上。這將是我們第一次看到真實的感測器數據,從物理世界流淌進我們的應用程式!
characteristic.readValue()這是我們工具箱裡的第一把鑰匙,專門用來打開那些標有 read 屬性的寶箱。
隸屬:BluetoothGATTCharacteristic 物件的方法。也就是我們之前探索到並儲存在 gattProfile 中的那個 characteristic 實例。
非同步:它是一個非同步操作,會回傳一個 Promise。因為從裝置讀取數據需要時間。
回傳值:當 Promise 成功解析時,它回傳的不是一個簡單的數字或字串,而是一個 DataView 物件。
DataView這是新手遇到的第一個「坎」,但理解它非常重要。
為什麼不是直接的數字?
藍牙裝置之間為了效率,溝通時傳送的不是我們熟悉的文字 '100',而是最原始、最底層的二進位數據(Bytes,位元組)。一個數字 100 在記憶體中可能只佔用 1 個位元組 01100100。
DataView 是什麼?
你可以把 readValue() 返回的原始數據想像成一塊「未經切割的生肉」(在 JS 中稱為 ArrayBuffer)。DataView 物件則像一把多功能的瑞士刀,它提供了一組工具(方法),讓我們可以從這塊生肉上,按照我們想要的方式,精準地切下需要的部分。
常用的「刀片」 (方法):
dataView.getUint8(byteOffset): 將指定位置(byteOffset)的 1 個位元組,解讀為一個 8 位元無符號整數(範圍 0-255)。這是最常用的方法之一,像電池電量就常用這種格式。
dataView.getInt16(byteOffset, littleEndian): 將指定位置的 2 個位元組,解讀為一個 16 位元整數。
還有 getFloat32、getUint32 等等,用來解讀更複雜的數據。
今天,我們將專注於最簡單的 getUint8(0),即讀取第一個位元組的數值。
Read 按鈕我們的戰場,依然是 Day 14 建立的 renderCharacteristic 函式。我們需要找到生成 Read 按鈕的那段 if 邏輯,並將它的 onclick 事件處理器徹底改造。
打開 app.js,修改 renderCharacteristic 函式:
首先,為了讓 onclick 函式能夠方便地找到它對應的 service UUID,我們需要稍微修改一下 renderCharacteristic 的參數。
// 修改函式簽名,增加 serviceUuid 參數
function renderCharacteristic(serviceUuid, charInfo, serviceCard) {
// ... 函式其他部分不變 ...
}
// 同時,在昨天呼叫它的地方,也要把 service.uuid 傳進去
// for (const service of services) {
// ...
// for (const characteristic of characteristics) {
// renderCharacteristic(service.uuid, { ... }, serviceCard); // 像這樣
// }
// }
接著,找到 if (charInfo.properties.read) 區塊,用下面的全新版本替換它:
// 在 renderCharacteristic 函式內部
if (charInfo.properties.read) {
const readButton = document.createElement('button');
readButton.textContent = 'Read';
// 將 onclick 事件處理器升級為 async 函式!
readButton.onclick = async () => {
log(`從特徵 ${charInfo.uuid} 讀取資料...`);
try {
// 步驟 1: 從我們的 gattProfile 中獲取特徵的原始實例
const characteristic = gattProfile.services[serviceUuid].characteristics[charInfo.uuid].instance;
if (!characteristic) {
log('錯誤: 找不到特徵實例。');
return;
}
// 步驟 2: 呼叫 readValue() API
const valueDataView = await characteristic.readValue();
log(`> 收到原始 DataView: ${valueDataView.byteLength} bytes`);
// 步驟 3: 解析數據 (此處以最簡單的 uint8 為例)
// getUint8(0) 表示讀取第 0 個 byte (也就是第一個 byte)
const value = valueDataView.getUint8(0);
log(`>> 解析後的數值: ${value}`);
// 步驟 4: 更新 UI 介面
valueSpan.textContent = value;
valueSpan.parentElement.style.backgroundColor = '#d4edda'; // 給個成功提示的背景色
// 步驟 5 (可選但推薦): 更新我們的中央資料庫
gattProfile.services[serviceUuid].characteristics[charInfo.uuid].value = value;
} catch (error) {
log(`[錯誤] 讀取特徵失敗: ${error.message}`);
valueSpan.parentElement.style.backgroundColor = '#f8d7da'; // 給個失敗提示的背景色
}
};
actionContainer.appendChild(readButton);
}
readButton.onclick = async () => { ... }: 因為 readValue() 是非同步的,我們的事件處理器必須是 async 函式,這樣才能在裡面使用 await。
gattProfile.services[...].characteristics[...].instance: 這是閉包強大威力的完美體現!onclick 函式被觸發時,它可以從它被創建時的「記憶」中,拿到 serviceUuid 和 charInfo.uuid,然後像查字典一樣,從我們的中央資料庫 gattProfile 中,精準地找到我們當初儲存的那個 characteristic 原始物件實例。只有這個實例,才有 .readValue() 方法。
await characteristic.readValue(): 程式執行到這裡會暫停,等待從藍牙裝置接收到數據。接收成功後,valueDataView 就會是我們得到的 DataView 物件。
UI 更新:我們不僅更新了 valueSpan 的文字,還順便改變了它父層容器的背景顏色,給使用者一個清晰的視覺回饋。
今天,我們為「Read」按鈕注入了真正的靈魂!
讀取操作是一次性的「拉取 (Pull)」——我們主動去要資料。但很多藍牙應用,比如心率監測或溫度計,更需要裝置在數據變化時,能持續地「推送 (Push)」給我們。這就是「訂閱通知 (Subscription)」的用武之地。
明天,我們將為「Subscribe」按鈕注入靈魂,學習如何使用 startNotifications() 來監聽來自裝置的即時數據流,讓我們的應用程式從一個「詢問者」,進化為一個「傾聽者」。
那麼今天的內容就到這邊,感謝你能看到這裡,在這邊祝你早安、午安、晚安,我們明天見。