2023-09-24

逆向工程 USB藍牙電量檢測器 訊號並使用 Web Bluetooth API 顯示資料

最近在下因為需要整理大量 USB線 ,並區分 電源線 及 傳輸線,並丟棄效能太低的 USB線
及 了解流動電池預計差電時間,因此尋找一些能夠偵測 電量資訊 及 差電時間 的 電量檢測器

測試工具外觀

見下文
USB藍牙電量檢測器 正面

見下文
USB藍牙電量檢測器 背面

見下文
WCHCH573F晶片,內置 藍牙低功耗 功能

見下文
JLBPOK240晶片,但無法找到相關的 資料表

見下文
接駁電源後會顯示 電量溫度累計時間 等資料

見下文
要透過藍牙檢視資料,必須在 Android 或 iOS 指定的應用程式才能顯示資料

但在下對於一定要使用電話應用程式檢視資料覺得非常麻煩
因此在下嘗試使用 Web Bluetooth API 來存取資料
如果成功,便可以在電腦上使用 網頁瀏覽器 檢視資料

偵測藍牙訊號

見下文
在下使用由 Nordic Semiconductor 提供的 藍牙訊號檢查工具 來分析藍牙裝置所發出的訊號

見下文
連接後會顯示藍牙裝置的 服務(Service)

見下文
點選 更多選項圖示 > Show log

見下文
將檢視內容的詳細程度改變為 DEBUG

見下文
除了顯示一般服務外,即使標示為 不明(Unknown) 都會顯示所有資料

例如在下使用的 USB藍牙電量檢測器 顯示資料
Unknown Service (0000ffe0-0000-1000-8000-00805f9b34fb)
- Unknown Characteristic [N W WNR] (0000ffe1-0000-1000-8000-00805f9b34fb)
  Client Characteristic Configuration (0x2902)
- Unknown Characteristic [W WNR] (0000ffe2-0000-1000-8000-00805f9b34fb)
當中的:
  • N 表示 通知(Notify)
  • W 表示 寫入(Write)
  • WNR 表示 寫入但不回應(Write No Response)
再顯示指令
gatt.setCharacteristicNotification(0000ffe1-0000-1000-8000-00805f9b34fb, true)
表示測試中,使用了 0000ffe1-0000-1000-8000-00805f9b34fb 特徵(Characteristic)

使用 0000ffe0-0000-1000-8000-00805f9b34fb 服務,並連接到 0000ffe1-0000-1000-8000-00805f9b34fb 特徵獲取資料
由於 0000ffe1-0000-1000-8000-00805f9b34fb 特徵 擁有 通知 屬性,藍牙裝置根據特定週期自動向 宿主(Host) 回應資料
而不需要 宿主 不斷主動發送請求來獲取資料

使用 Web Bluetooth API 連接

見下文
見下文
暫時只有 Chromium Base 的網頁瀏覽器才支援 Web Bluetooth API ,在下使用 Chrome 測試
Chrome 的 Web Bluetooth API 預設沒有啟動,需要到 chrome://flags 搜尋 Bluetooth
並將 Web Bluetooth API 的功能啟動,再重新啟動網頁瀏覽器,才能使用 Web Bluetooth API

見下文
在下製作一個簡單的 HTML檔案 測試藍牙連接
// javascript.js
function WebBluetoothAPI(serviceUUID, characteristicUUID) {
	this.connect = function() {
		navigator.bluetooth.requestDevice({
			"filters": [
				{
					"services": [
						serviceUUID
					]
				}
			]
		}).then(function(device) {
			console.log("device found");
			return device.gatt.connect();
		}).then(function(server) {
			console.log("server found");
			return server.getPrimaryService(serviceUUID);
		}).then(function(service) {
			console.log("service found");
			return service.getCharacteristic(characteristicUUID);
		}).then(function(characteristic) {
			console.log("characteristic found");
		}).catch(function(exception) {
			console.log(exception);
		});
	};
}
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
	<head>
		<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
		<title>Web Bluetooth API</title>
		<script src="javascript.js"></script>
		<script>
var webBluetoothAPI = new WebBluetoothAPI("0000ffe0-0000-1000-8000-00805f9b34fb", "0000ffe1-0000-1000-8000-00805f9b34fb");
		</script>
	</head>
	<body>
		<button onclick="webBluetoothAPI.connect();">Connect</button>
	</body>
</html>
按下 Connect 後,會顯示附近的 藍牙訊號
選擇 USB藍牙電量檢測器 的裝置,然後按 配對,便會連接 藍牙裝置

見下文
由於在下使用 console.log 輸出資料,因此需要開啟 開發人員工具 檢視輸出內容

見下文
接駁到 負載(Load) ,USB藍牙電量檢測器 會顯示差電資料,並開始計時

見下文
確認能夠連接到 藍牙裝置 後,嘗試獲取 通知 的資料
// javascript.js
function WebBluetoothAPI(serviceUUID, characteristicUUID) {
	this.connect = function() {
		navigator.bluetooth.requestDevice({
			"filters": [
				{
					"services": [
						serviceUUID
					]
				}
			]
		}).then(function(device) {
			console.log("device found");
			return device.gatt.connect();
		}).then(function(server) {
			console.log("server found");
			return server.getPrimaryService(serviceUUID);
		}).then(function(service) {
			console.log("service found");
			return service.getCharacteristic(characteristicUUID);
		}).then(function(characteristic) {
			console.log("characteristic found");
			characteristic.startNotifications().then(function() {
				console.log("notification started");
				characteristic.addEventListener("characteristicvaluechanged", characteristicvaluechangedEventHandler);
			});
		}).catch(function(exception) {
			console.log(exception);
		});
	};
	var characteristicvaluechangedEventHandler = function(characteristicvaluechangedEvent) {
		let byteArray = new Uint8Array(characteristicvaluechangedEvent.target.value.buffer);
		console.log(byteArray.join(","));
	};
}

分析訊號

開發人員工具 顯示 序列資訊,例如:
255,85,1,3,0,1,226,0,0,43,0,1,216,0,0,0,232,0,7,1,40,3,32,0,1,4,33,60,9,105,0,0,1,44,0,248
根據由 https://github.com/NiceLabs/atorch-console 的資料及在下的觀察
這款 U96PB 的 USB藍牙電量檢測器 序列資料的內容如下:
偏移 位元組長度 類型 功能
十進制 十六進制
0 0x00 2 無符號數值 標頭
1 0x01
2 0x02 1 無符號數值 操作類型
  • 0x01 為 報告(Report)
  • 0x02 為 回應(Reply)
  • 0x11 為 指令(Command)
3 0x03 1 無符號數值 差電類型
  • 0x01 為 直流電(DC)
  • 0x02 為 交流電(AC)
  • 0x03 為 USB
4 0x04 3 無符號數值 電壓(Voltage)
(需要 除以100)
5 0x05
6 0x06
7 0x07 3 無符號數值 電流(Current)
(需要 除以100)
8 0x08
9 0x09
10 0x0A 3 無符號數值 累計毫安每小時(mAh)
11 0x0B
12 0x0C
13 0x0D 4 無符號數值 累計功率每小時(Wh)
(需要 除以100)
14 0x0E
15 0x0F
16 0x10
17 0x11 2 無符號數值 USB D- 訊號電壓
(需要 除以100)
18 0x12
19 0x13 2 無符號數值 USB D+ 訊號電壓
(需要 除以100)
20 0x14
21 0x15 2 有符號數值 攝氏度(Degree Celsius),第15位元表達正負
當資料為負數時,要以二補碼方式計算
22 0x16
23 0x17 2 無符號數值 累計差電時數(Total Charge Hours)
24 0x18
25 0x19 1 無符號數值 累計差電分鐘(Total Charge Minutes)
26 0x1A 1 無符號數值 累計差電秒數(Total Charge Seconds)
27 0x1B 1 無符號數值 自動休眠秒數(Auto Sleep Seconds)
28 0x1C 2 無符號數值 最高保護電壓(Maximum Protection Voltage)
(需要 除以100)
29 0x1D
30 0x1E 2 無符號數值 最低保護電壓(Minimum Protection Voltage)
(需要 除以100)
31 0x1F
32 0x20 2 無符號數值 最高保護電流(Maximum Protection Current)
(需要 除以100)
33 0x21
34 0x22 1 無符號數值 背光光度(Backlight)
35 0x23 1 無符號數值 校驗和(Checksum)
由於 電阻功率華氏度 是計算而來的資料,因此不會在序列資料中顯示
但要計算這些資料亦很簡單:
  • 電阻 = 電壓 / 電流
  • 功率 = 電壓 * 電流
  • 華氏度 = 攝氏度 * 9 / 5 + 32

留意:當沒有負載時,電流為0,會導致計算電阻時會出錯(除以0為沒有意義)

另外有些資料由多組資料表示,例如例子中的電壓資料為 0x00, 0x01, 0xE2
由於資料以 最高位順序(Big Endian) ,即是資料會由陣列中最高位開始計算
(0xE2 + (0x01 << 8) + (0x00 << 16)) / 100
計算結果是 4.82

編寫以 最高位順序 陣列計算的功能
function calculate(byteArray, signed) {
	let sum = 0;
	for (let i = 0; i < byteArray.length; i++) {
		sum += byteArray[byteArray.length - i - 1] << (8 * i);
	}
	// 使用二補數來計算有符號數值
	if (signed && (sum >> (8 * byteArray.length - 1)) > 0) {
		sum ^= -(1 << (8 * byteArray.length));
	}
	return sum;
}
// byteArray.slice(4, 7) 會截取 byteArray[4], byteArray[5], byteArray[6] 的資料
// 並傳回 索引(index) 由 0 開始的陣列
console.log(calculate(byteArray.slice(4, 7)) / 100);
// 電壓的計算結果需要 除以 100
見下文
能夠正確計算資料後,便可以透過網頁顯示資料

控制訊號

除了讀取資料,還可以將資料寫入來控制 USB藍牙電量檢測器
偏移 資料 功能
十進制 十六進制 十進制 十六進制
0 0x00 255 0xFF 標頭
1 0x01 85 0x55
2 0x02 17 0x11 宣告序列訊號為指令操作
3 0x03 3 0x03 差電類型,與報告相同
4 0x04 ? ? 指令
5 0x05 ? ? 4位元組參數(最高位順序)
6 0x06 ? ?
7 0x07 ? ?
8 0x08 ? ?
9 0x09 ? ? 校驗和

指令及功能

控制 USB藍牙電量檢測器 需要對應的指令
指令 功能
十進制 十六進制
1 0x01 重設累計功率每小時(Reset Wh)
2 0x02 重設累計毫安培每小時(Reset mAh)
3 0x03 重設累計時間(Reset Time)
5 0x05 前往下一筆紀錄(Next Record)
49 0x31 進入設定模式(Setup)
50 0x32 離開設定模式(Exit)
51 0x33 移到上一頁或增加設定值(Next/Inceament)
52 0x34 移到下一頁或減少設定值(Previous/Deceament)
指令要正確寫入,最麻煩是需要計算 校驗和(Checksum)
幸好 https://github.com/NiceLabs/atorch-console 都有提供 校驗和 的計算方法
計算方法使用 Lambda語法 編寫,雖然 Lambda語法 減少語法內容,但在下認為這種語法比傳統計算方法複雜
因此在下修改為以 For Loop 編寫,比較更容易明白,移植亦比較簡單
function checksum(byteArray) {
	let checksum = 0;
	for (let i = 2; i < byteArray.length - 1; i++) {
		checksum += byteArray[i] & 0xFF;
	}
	// the last element is checksum
	byteArray[byteArray.length - 1] = checksum ^ 0x44;
	return byteArray;
}
let byteArray = checkcum([0xFF, 0x55, 0x11, 0x03, 0x33, 0x00, 0x00, 0x00, 0x00, 0x00]);
// javascript.js
function WebBluetoothAPI(serviceUUID, characteristicUUID) {
	var serviceCharacteristic;
  	this.connect = function() {
		navigator.bluetooth.requestDevice({
			"filters": [
				{
					"services": [
						serviceUUID
					]
				}
			]
		}).then(function(device) {
			console.log("device found");
			return device.gatt.connect();
		}).then(function(server) {
			console.log("server found");
			return server.getPrimaryService(serviceUUID);
		}).then(function(service) {
			console.log("service found");
			return service.getCharacteristic(characteristicUUID);
		}).then(function(characteristic) {
			console.log("characteristic found");
			serviceCharacteristic = characteristic;
			characteristic.startNotifications().then(function() {
				console.log("notification started");
				characteristic.addEventListener("characteristicvaluechanged", characteristicvaluechangedEventHandler);
			});
		}).catch(function(exception) {
			console.log(exception);
		});
	};
	var characteristicvaluechangedEventHandler = function(characteristicvaluechangedEvent) {
		let byteArray = new Uint8Array(characteristicvaluechangedEvent.target.value.buffer);
		let data = {};
		data["header"] = [byteArray[0], byteArray[1]];
		data["operation"] = byteArray[2];
		data["charge"] = byteArray[3];
		data["voltage"] = calculate(byteArray.slice(4, 7)) / 100;
		data["current"] = calculate(byteArray.slice(7, 10)) / 100;
		data["mah"] = calculate(byteArray.slice(10, 13));
		data["wh"] = calculate(byteArray.slice(13, 17)) / 100;
		data["usb-"] = calculate(byteArray.slice(17, 19)) / 100;
		data["usb+"] = calculate(byteArray.slice(19, 21)) / 100;
		data["temperature"] = byteArray[22];
		data["hours"] = calculate(byteArray.slice(23, 25));
		data["minutes"] = byteArray[25];
		data["seconds"] = byteArray[26];
		data["sleep"] = byteArray[27];
		data["max-voltage"] = calculate(byteArray.slice(28, 30)) / 100;
		data["min-voltage"] = calculate(byteArray.slice(30, 32)) / 100;
		data["max-current"] = calculate(byteArray.slice(32, 34)) / 100;
		data["backlight"] = byteArray[34];
		data["checksum"] = byteArray[35];
		console.log(Object.keys(data).map(function(key) {
			return key + " = " + data[key];
		}).join("\n"));
	};
	var calculate = function(byteArray, signed) {
		let sum = 0;
		for (let i = 0; i < byteArray.length; i++) {
			sum += byteArray[byteArray.length - i - 1] << (8 * i);
		}
		if (signed && (sum & (1 << (8 * byteArray.length - 1))) > 0) {
			sum ^= -(1 << (8 * byteArray.length));
		}
		return sum;
	}
	var setChecksum = function(byteArray) {
		let checksum = 0;
		for (let i = 2; i < byteArray.length - 1; i++) {
			checksum = (checksum + byteArray[i]) & 0xFF;
		}
		byteArray[byteArray.length - 1] = checksum ^ 0x44;
		return byteArray;
	};
	var write = function(byteArray) {
		byteArray = setChecksum(byteArray);
		byteArray = new Uint8Array(byteArray);
		serviceCharacteristic.writeValue(byteArray.buffer);
	};
	this.next = function() {
		write([0xFF, 0x55, 0x11, 0x03, 0x33, 0x00, 0x00, 0x00, 0x00, 0x00]);
	};
}
<!-- index.html -->
<!DOCTYPE html>
<html lang="en">
	<head>
		<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
		<title>Web Bluetooth API</title>
		<script src="javascript.js"></script>
		<script>
var webBluetoothAPI = new WebBluetoothAPI("0000ffe0-0000-1000-8000-00805f9b34fb", "0000ffe1-0000-1000-8000-00805f9b34fb");
		</script>
	</head>
	<body>
		<button onclick="webBluetoothAPI.connect();">Connect</button>
		<button onclick="webBluetoothAPI.next();">Connect</button>
	</body>
</html>
見下文
可以透過網頁控制 USB藍牙電量檢測器

總結

當 宿主程式 使用 射頻通訊(Radio frequency Communication (RFCOMM)) 偵測訊號時
會顯示 USB藍牙電量檢測器 的藍牙名稱為 UC96_SPP
而使用 藍牙低功耗(Bluetooth Low Energy (BLE)) 會顯示 UC96_BLE
即是裝置支援舊版本或新版本的藍牙通訊模式

過去都曾經試用 Web Bluetooth API ,但當時只是因為測試效果,並沒有詳細理解原理
但這次在下想正式使用 Web Bluetooth API 向藍牙裝置接收及發送訊號,因此正式學習使用方法

當中在下最大的發現是,當使用 Web Bluetooth API 連接服務或特徵時
例如在下正在使用 USB藍牙電量檢測器 需要以 UUID 的方式連接時
UUID 的英文字母必須使用小寫 才能連接,大寫是不被接受

在下並不知道有其他人都有逆向工程這種 USB藍牙電量檢測器 的想法
最初分析藍牙訊號時,花了大量時間不斷比較每組資料與 USB藍牙電量檢測器 的差異
但當中仍然有大量資訊無法分析,在網上尋找資料時,發現有相似的專案
雖然專案中並沒有提供與在下使用的 USB藍牙電量檢測器 的相同型號資料,但其他型號仍然有參考作用
因此進度大幅提升

越來越多智能產品需要使用 Android 或 iOS 的應用程式來讀取或控制
但在下覺得這種設計的限制非常大;其實網頁技術越來越全面,方便不同平台才是良好的設計
反而是某些平台或系統限制使用者及開發者的選擇,結果只能以不方便的方法使用科技產品
當平台或系統失效時,跟隨的工具都會一同失效,使用者及開發者同樣損失

在下將網頁版上載到 https://hkgoldenmra.bitbucket.io/html5-web-bluetooth-atorch-serial-monitor/
如果閣下有興趣,可以直接使用,或下載使用

參考資料

沒有留言 :

張貼留言