bluetooth.js 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329
  1. /**
  2. * 蓝牙秤工具模块 - BT-YH
  3. * Service UUID: 49535343-FE7D-4AE5-8FA9-9FAFD205E455
  4. * Characteristic UUID: 49535343-1E4D-4BD9-BA61-23C647249616
  5. * 数据格式: ASCII 字符串, 正则 /=(\d+\.\d+)/ 提取重量(kg)
  6. */
  7. const DEVICE_NAME = 'BT-YH';
  8. const SERVICE_UUID = '49535343-FE7D-4AE5-8FA9-9FAFD205E455';
  9. const CHARACTERISTIC_UUID = '49535343-1E4D-4BD9-BA61-23C647249616';
  10. // ArrayBuffer 转 ASCII 字符串
  11. function ab2str(buffer) {
  12. const arr = new Uint8Array(buffer);
  13. let str = '';
  14. for (let i = 0; i < arr.length; i++) {
  15. str += String.fromCharCode(arr[i]);
  16. }
  17. return str;
  18. }
  19. // 从原始字符串中提取重量值
  20. function parseWeight(str) {
  21. const match = str.match(/=(\d+\.\d+)/);
  22. if (match) {
  23. const val = parseFloat(match[1]);
  24. if (!isNaN(val)) return val;
  25. }
  26. return null;
  27. }
  28. // UUID 统一转大写,兼容 iOS/Android 差异
  29. function normalizeUUID(uuid) {
  30. return uuid.toUpperCase();
  31. }
  32. class BluetoothScale {
  33. constructor() {
  34. this._deviceId = null;
  35. this._serviceId = null;
  36. this._characteristicId = null;
  37. this._buffer = ''; // 分包拼接缓冲
  38. this._lastWeight = null; // 上一次稳定值
  39. this._stableCount = 0; // 连续相同次数
  40. this._onWeight = null; // 稳定重量回调
  41. this._onStatus = null; // 状态变更回调
  42. this._onSelectDevice = null; // 多设备选择回调
  43. this._scanning = false;
  44. this._connected = false;
  45. }
  46. // 注册回调
  47. onWeight(fn) { this._onWeight = fn; }
  48. onStatus(fn) { this._onStatus = fn; }
  49. // 多设备时触发,参数为设备列表,调用方需 resolve(deviceId) 或 reject()
  50. onSelectDevice(fn) { this._onSelectDevice = fn; }
  51. _setStatus(msg) {
  52. console.log('[BT]', msg);
  53. if (this._onStatus) this._onStatus(msg);
  54. }
  55. // 处理收到的数据包
  56. _handleData(buffer) {
  57. const chunk = ab2str(buffer);
  58. this._buffer += chunk;
  59. // 避免缓冲区过大
  60. if (this._buffer.length > 200) {
  61. this._buffer = this._buffer.slice(-100);
  62. }
  63. const weight = parseWeight(this._buffer);
  64. if (weight !== null) {
  65. if (weight === this._lastWeight) {
  66. this._stableCount++;
  67. // 连续2次相同认为稳定
  68. if (this._stableCount >= 2 && this._onWeight) {
  69. this._onWeight(weight);
  70. }
  71. } else {
  72. this._lastWeight = weight;
  73. this._stableCount = 1;
  74. }
  75. }
  76. }
  77. // 初始化蓝牙适配器
  78. _openAdapter() {
  79. return new Promise((resolve, reject) => {
  80. wx.openBluetoothAdapter({
  81. success: resolve,
  82. fail: (err) => {
  83. if (err.errCode === 10001) {
  84. reject(new Error('请先开启手机蓝牙'));
  85. } else {
  86. reject(new Error('蓝牙初始化失败: ' + err.errMsg));
  87. }
  88. }
  89. });
  90. });
  91. }
  92. // 扫描目标设备,固定扫5秒收集所有 BT-YH,多台时交给调用方选择
  93. _scanDevice() {
  94. return new Promise((resolve, reject) => {
  95. this._scanning = true;
  96. this._setStatus('正在搜索蓝牙秤...');
  97. const foundDevices = []; // 收集所有找到的目标设备
  98. const done = () => {
  99. wx.stopBluetoothDevicesDiscovery();
  100. this._scanning = false;
  101. if (foundDevices.length === 0) {
  102. reject(new Error('未找到蓝牙秤,请确认秤已开机并在附近'));
  103. return;
  104. }
  105. if (foundDevices.length === 1) {
  106. // 只有一台,直接连
  107. resolve(foundDevices[0].deviceId);
  108. return;
  109. }
  110. // 多台,交给调用方弹选择框
  111. if (this._onSelectDevice) {
  112. this._onSelectDevice(foundDevices, (deviceId) => {
  113. if (deviceId) {
  114. resolve(deviceId);
  115. } else {
  116. reject(new Error('已取消选择'));
  117. }
  118. });
  119. } else {
  120. // 没注册回调时默认取第一台
  121. resolve(foundDevices[0].deviceId);
  122. }
  123. };
  124. // 5秒后结束扫描
  125. const timer = setTimeout(done, 5000);
  126. wx.startBluetoothDevicesDiscovery({
  127. services: [SERVICE_UUID],
  128. allowDuplicatesKey: false,
  129. success: () => {
  130. wx.onBluetoothDeviceFound((res) => {
  131. const devices = res.devices || [];
  132. for (const device of devices) {
  133. const name = (device.name || device.localName || '').trim();
  134. if (name === DEVICE_NAME) {
  135. // 去重后加入列表
  136. const exists = foundDevices.some(d => d.deviceId === device.deviceId);
  137. if (!exists) {
  138. foundDevices.push({
  139. deviceId: device.deviceId,
  140. name: name,
  141. rssi: device.RSSI || 0,
  142. });
  143. this._setStatus('已发现 ' + foundDevices.length + ' 台蓝牙秤...');
  144. }
  145. }
  146. }
  147. });
  148. },
  149. fail: (err) => {
  150. clearTimeout(timer);
  151. this._scanning = false;
  152. reject(new Error('启动扫描失败: ' + err.errMsg));
  153. }
  154. });
  155. });
  156. }
  157. // 连接设备
  158. _connect(deviceId) {
  159. return new Promise((resolve, reject) => {
  160. this._setStatus('正在连接蓝牙秤...');
  161. wx.createBLEConnection({
  162. deviceId,
  163. timeout: 10000,
  164. success: () => {
  165. this._deviceId = deviceId;
  166. this._connected = true;
  167. resolve();
  168. },
  169. fail: (err) => {
  170. reject(new Error('连接失败: ' + err.errMsg));
  171. }
  172. });
  173. });
  174. }
  175. // 获取服务列表,找到目标 Service
  176. _getService() {
  177. return new Promise((resolve, reject) => {
  178. // iOS 需要延迟一点再获取服务
  179. setTimeout(() => {
  180. wx.getBLEDeviceServices({
  181. deviceId: this._deviceId,
  182. success: (res) => {
  183. const target = res.services.find(
  184. s => normalizeUUID(s.uuid) === normalizeUUID(SERVICE_UUID)
  185. );
  186. if (target) {
  187. this._serviceId = target.uuid;
  188. resolve();
  189. } else {
  190. reject(new Error('未找到目标蓝牙服务'));
  191. }
  192. },
  193. fail: (err) => reject(new Error('获取服务失败: ' + err.errMsg))
  194. });
  195. }, 500);
  196. });
  197. }
  198. // 获取特征值,找到目标 Characteristic
  199. _getCharacteristic() {
  200. return new Promise((resolve, reject) => {
  201. wx.getBLEDeviceCharacteristics({
  202. deviceId: this._deviceId,
  203. serviceId: this._serviceId,
  204. success: (res) => {
  205. const target = res.characteristics.find(
  206. c => normalizeUUID(c.uuid) === normalizeUUID(CHARACTERISTIC_UUID)
  207. );
  208. if (target) {
  209. this._characteristicId = target.uuid;
  210. resolve();
  211. } else {
  212. // 未精确匹配时,取第一个支持 notify 的特征值
  213. const notifyChar = res.characteristics.find(
  214. c => c.properties && c.properties.notify
  215. );
  216. if (notifyChar) {
  217. this._characteristicId = notifyChar.uuid;
  218. resolve();
  219. } else {
  220. reject(new Error('未找到可订阅的特征值'));
  221. }
  222. }
  223. },
  224. fail: (err) => reject(new Error('获取特征值失败: ' + err.errMsg))
  225. });
  226. });
  227. }
  228. // 开启 notify 订阅
  229. _subscribeNotify() {
  230. return new Promise((resolve, reject) => {
  231. this._setStatus('正在订阅数据...');
  232. wx.notifyBLECharacteristicValueChange({
  233. deviceId: this._deviceId,
  234. serviceId: this._serviceId,
  235. characteristicId: this._characteristicId,
  236. state: true,
  237. success: () => {
  238. // 注册数据接收回调
  239. wx.onBLECharacteristicValueChange((res) => {
  240. if (
  241. res.deviceId === this._deviceId &&
  242. normalizeUUID(res.characteristicId) === normalizeUUID(this._characteristicId)
  243. ) {
  244. this._handleData(res.value);
  245. }
  246. });
  247. resolve();
  248. },
  249. fail: (err) => reject(new Error('订阅失败: ' + err.errMsg))
  250. });
  251. });
  252. }
  253. /**
  254. * 启动蓝牙秤连接
  255. * 返回 Promise,resolve 后可通过 onWeight 回调接收数据
  256. */
  257. async start() {
  258. try {
  259. this._buffer = '';
  260. this._lastWeight = null;
  261. this._stableCount = 0;
  262. await this._openAdapter();
  263. const deviceId = await this._scanDevice();
  264. await this._connect(deviceId);
  265. await this._getService();
  266. await this._getCharacteristic();
  267. await this._subscribeNotify();
  268. this._setStatus('已连接,等待读数...');
  269. } catch (err) {
  270. this._setStatus(err.message);
  271. this.stop();
  272. throw err;
  273. }
  274. }
  275. /**
  276. * 断开并清理
  277. */
  278. stop() {
  279. // 关闭 notify
  280. if (this._connected && this._deviceId && this._serviceId && this._characteristicId) {
  281. wx.notifyBLECharacteristicValueChange({
  282. deviceId: this._deviceId,
  283. serviceId: this._serviceId,
  284. characteristicId: this._characteristicId,
  285. state: false,
  286. fail: () => {}
  287. });
  288. }
  289. if (this._deviceId) {
  290. wx.closeBLEConnection({ deviceId: this._deviceId, fail: () => {} });
  291. }
  292. if (this._scanning) {
  293. wx.stopBluetoothDevicesDiscovery();
  294. }
  295. wx.offBLECharacteristicValueChange && wx.offBLECharacteristicValueChange();
  296. this._deviceId = null;
  297. this._serviceId = null;
  298. this._characteristicId = null;
  299. this._connected = false;
  300. this._scanning = false;
  301. this._buffer = '';
  302. this._lastWeight = null;
  303. this._stableCount = 0;
  304. }
  305. }
  306. export default BluetoothScale;