|
@@ -0,0 +1,328 @@
|
|
|
|
|
+/**
|
|
|
|
|
+ * 蓝牙秤工具模块 - BT-YH
|
|
|
|
|
+ * Service UUID: 49535343-FE7D-4AE5-8FA9-9FAFD205E455
|
|
|
|
|
+ * Characteristic UUID: 49535343-1E4D-4BD9-BA61-23C647249616
|
|
|
|
|
+ * 数据格式: ASCII 字符串, 正则 /=(\d+\.\d+)/ 提取重量(kg)
|
|
|
|
|
+ */
|
|
|
|
|
+
|
|
|
|
|
+const DEVICE_NAME = 'BT-YH';
|
|
|
|
|
+const SERVICE_UUID = '49535343-FE7D-4AE5-8FA9-9FAFD205E455';
|
|
|
|
|
+const CHARACTERISTIC_UUID = '49535343-1E4D-4BD9-BA61-23C647249616';
|
|
|
|
|
+
|
|
|
|
|
+// ArrayBuffer 转 ASCII 字符串
|
|
|
|
|
+function ab2str(buffer) {
|
|
|
|
|
+ const arr = new Uint8Array(buffer);
|
|
|
|
|
+ let str = '';
|
|
|
|
|
+ for (let i = 0; i < arr.length; i++) {
|
|
|
|
|
+ str += String.fromCharCode(arr[i]);
|
|
|
|
|
+ }
|
|
|
|
|
+ return str;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// 从原始字符串中提取重量值
|
|
|
|
|
+function parseWeight(str) {
|
|
|
|
|
+ const match = str.match(/=(\d+\.\d+)/);
|
|
|
|
|
+ if (match) {
|
|
|
|
|
+ const val = parseFloat(match[1]);
|
|
|
|
|
+ if (!isNaN(val)) return val;
|
|
|
|
|
+ }
|
|
|
|
|
+ return null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// UUID 统一转大写,兼容 iOS/Android 差异
|
|
|
|
|
+function normalizeUUID(uuid) {
|
|
|
|
|
+ return uuid.toUpperCase();
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+class BluetoothScale {
|
|
|
|
|
+ constructor() {
|
|
|
|
|
+ this._deviceId = null;
|
|
|
|
|
+ this._serviceId = null;
|
|
|
|
|
+ this._characteristicId = null;
|
|
|
|
|
+ this._buffer = ''; // 分包拼接缓冲
|
|
|
|
|
+ this._lastWeight = null; // 上一次稳定值
|
|
|
|
|
+ this._stableCount = 0; // 连续相同次数
|
|
|
|
|
+ this._onWeight = null; // 稳定重量回调
|
|
|
|
|
+ this._onStatus = null; // 状态变更回调
|
|
|
|
|
+ this._onSelectDevice = null; // 多设备选择回调
|
|
|
|
|
+ this._scanning = false;
|
|
|
|
|
+ this._connected = false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 注册回调
|
|
|
|
|
+ onWeight(fn) { this._onWeight = fn; }
|
|
|
|
|
+ onStatus(fn) { this._onStatus = fn; }
|
|
|
|
|
+ // 多设备时触发,参数为设备列表,调用方需 resolve(deviceId) 或 reject()
|
|
|
|
|
+ onSelectDevice(fn) { this._onSelectDevice = fn; }
|
|
|
|
|
+
|
|
|
|
|
+ _setStatus(msg) {
|
|
|
|
|
+ console.log('[BT]', msg);
|
|
|
|
|
+ if (this._onStatus) this._onStatus(msg);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 处理收到的数据包
|
|
|
|
|
+ _handleData(buffer) {
|
|
|
|
|
+ const chunk = ab2str(buffer);
|
|
|
|
|
+ this._buffer += chunk;
|
|
|
|
|
+ // 避免缓冲区过大
|
|
|
|
|
+ if (this._buffer.length > 200) {
|
|
|
|
|
+ this._buffer = this._buffer.slice(-100);
|
|
|
|
|
+ }
|
|
|
|
|
+ const weight = parseWeight(this._buffer);
|
|
|
|
|
+ if (weight !== null) {
|
|
|
|
|
+ if (weight === this._lastWeight) {
|
|
|
|
|
+ this._stableCount++;
|
|
|
|
|
+ // 连续2次相同认为稳定
|
|
|
|
|
+ if (this._stableCount >= 2 && this._onWeight) {
|
|
|
|
|
+ this._onWeight(weight);
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ this._lastWeight = weight;
|
|
|
|
|
+ this._stableCount = 1;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 初始化蓝牙适配器
|
|
|
|
|
+ _openAdapter() {
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
+ wx.openBluetoothAdapter({
|
|
|
|
|
+ success: resolve,
|
|
|
|
|
+ fail: (err) => {
|
|
|
|
|
+ if (err.errCode === 10001) {
|
|
|
|
|
+ reject(new Error('请先开启手机蓝牙'));
|
|
|
|
|
+ } else {
|
|
|
|
|
+ reject(new Error('蓝牙初始化失败: ' + err.errMsg));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 扫描目标设备,固定扫5秒收集所有 BT-YH,多台时交给调用方选择
|
|
|
|
|
+ _scanDevice() {
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
+ this._scanning = true;
|
|
|
|
|
+ this._setStatus('正在搜索蓝牙秤...');
|
|
|
|
|
+ const foundDevices = []; // 收集所有找到的目标设备
|
|
|
|
|
+
|
|
|
|
|
+ const done = () => {
|
|
|
|
|
+ wx.stopBluetoothDevicesDiscovery();
|
|
|
|
|
+ this._scanning = false;
|
|
|
|
|
+
|
|
|
|
|
+ if (foundDevices.length === 0) {
|
|
|
|
|
+ reject(new Error('未找到蓝牙秤,请确认秤已开机并在附近'));
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (foundDevices.length === 1) {
|
|
|
|
|
+ // 只有一台,直接连
|
|
|
|
|
+ resolve(foundDevices[0].deviceId);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ // 多台,交给调用方弹选择框
|
|
|
|
|
+ if (this._onSelectDevice) {
|
|
|
|
|
+ this._onSelectDevice(foundDevices, (deviceId) => {
|
|
|
|
|
+ if (deviceId) {
|
|
|
|
|
+ resolve(deviceId);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ reject(new Error('已取消选择'));
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 没注册回调时默认取第一台
|
|
|
|
|
+ resolve(foundDevices[0].deviceId);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 5秒后结束扫描
|
|
|
|
|
+ const timer = setTimeout(done, 5000);
|
|
|
|
|
+
|
|
|
|
|
+ wx.startBluetoothDevicesDiscovery({
|
|
|
|
|
+ services: [SERVICE_UUID],
|
|
|
|
|
+ allowDuplicatesKey: false,
|
|
|
|
|
+ success: () => {
|
|
|
|
|
+ wx.onBluetoothDeviceFound((res) => {
|
|
|
|
|
+ const devices = res.devices || [];
|
|
|
|
|
+ for (const device of devices) {
|
|
|
|
|
+ const name = (device.name || device.localName || '').trim();
|
|
|
|
|
+ if (name === DEVICE_NAME) {
|
|
|
|
|
+ // 去重后加入列表
|
|
|
|
|
+ const exists = foundDevices.some(d => d.deviceId === device.deviceId);
|
|
|
|
|
+ if (!exists) {
|
|
|
|
|
+ foundDevices.push({
|
|
|
|
|
+ deviceId: device.deviceId,
|
|
|
|
|
+ name: name,
|
|
|
|
|
+ rssi: device.RSSI || 0,
|
|
|
|
|
+ });
|
|
|
|
|
+ this._setStatus('已发现 ' + foundDevices.length + ' 台蓝牙秤...');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ },
|
|
|
|
|
+ fail: (err) => {
|
|
|
|
|
+ clearTimeout(timer);
|
|
|
|
|
+ this._scanning = false;
|
|
|
|
|
+ reject(new Error('启动扫描失败: ' + err.errMsg));
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 连接设备
|
|
|
|
|
+ _connect(deviceId) {
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
+ this._setStatus('正在连接蓝牙秤...');
|
|
|
|
|
+ wx.createBLEConnection({
|
|
|
|
|
+ deviceId,
|
|
|
|
|
+ timeout: 10000,
|
|
|
|
|
+ success: () => {
|
|
|
|
|
+ this._deviceId = deviceId;
|
|
|
|
|
+ this._connected = true;
|
|
|
|
|
+ resolve();
|
|
|
|
|
+ },
|
|
|
|
|
+ fail: (err) => {
|
|
|
|
|
+ reject(new Error('连接失败: ' + err.errMsg));
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 获取服务列表,找到目标 Service
|
|
|
|
|
+ _getService() {
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
+ // iOS 需要延迟一点再获取服务
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ wx.getBLEDeviceServices({
|
|
|
|
|
+ deviceId: this._deviceId,
|
|
|
|
|
+ success: (res) => {
|
|
|
|
|
+ const target = res.services.find(
|
|
|
|
|
+ s => normalizeUUID(s.uuid) === normalizeUUID(SERVICE_UUID)
|
|
|
|
|
+ );
|
|
|
|
|
+ if (target) {
|
|
|
|
|
+ this._serviceId = target.uuid;
|
|
|
|
|
+ resolve();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ reject(new Error('未找到目标蓝牙服务'));
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ fail: (err) => reject(new Error('获取服务失败: ' + err.errMsg))
|
|
|
|
|
+ });
|
|
|
|
|
+ }, 500);
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 获取特征值,找到目标 Characteristic
|
|
|
|
|
+ _getCharacteristic() {
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
+ wx.getBLEDeviceCharacteristics({
|
|
|
|
|
+ deviceId: this._deviceId,
|
|
|
|
|
+ serviceId: this._serviceId,
|
|
|
|
|
+ success: (res) => {
|
|
|
|
|
+ const target = res.characteristics.find(
|
|
|
|
|
+ c => normalizeUUID(c.uuid) === normalizeUUID(CHARACTERISTIC_UUID)
|
|
|
|
|
+ );
|
|
|
|
|
+ if (target) {
|
|
|
|
|
+ this._characteristicId = target.uuid;
|
|
|
|
|
+ resolve();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ // 未精确匹配时,取第一个支持 notify 的特征值
|
|
|
|
|
+ const notifyChar = res.characteristics.find(
|
|
|
|
|
+ c => c.properties && c.properties.notify
|
|
|
|
|
+ );
|
|
|
|
|
+ if (notifyChar) {
|
|
|
|
|
+ this._characteristicId = notifyChar.uuid;
|
|
|
|
|
+ resolve();
|
|
|
|
|
+ } else {
|
|
|
|
|
+ reject(new Error('未找到可订阅的特征值'));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ fail: (err) => reject(new Error('获取特征值失败: ' + err.errMsg))
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 开启 notify 订阅
|
|
|
|
|
+ _subscribeNotify() {
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
+ this._setStatus('正在订阅数据...');
|
|
|
|
|
+ wx.notifyBLECharacteristicValueChange({
|
|
|
|
|
+ deviceId: this._deviceId,
|
|
|
|
|
+ serviceId: this._serviceId,
|
|
|
|
|
+ characteristicId: this._characteristicId,
|
|
|
|
|
+ state: true,
|
|
|
|
|
+ success: () => {
|
|
|
|
|
+ // 注册数据接收回调
|
|
|
|
|
+ wx.onBLECharacteristicValueChange((res) => {
|
|
|
|
|
+ if (
|
|
|
|
|
+ res.deviceId === this._deviceId &&
|
|
|
|
|
+ normalizeUUID(res.characteristicId) === normalizeUUID(this._characteristicId)
|
|
|
|
|
+ ) {
|
|
|
|
|
+ this._handleData(res.value);
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+ resolve();
|
|
|
|
|
+ },
|
|
|
|
|
+ fail: (err) => reject(new Error('订阅失败: ' + err.errMsg))
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 启动蓝牙秤连接
|
|
|
|
|
+ * 返回 Promise,resolve 后可通过 onWeight 回调接收数据
|
|
|
|
|
+ */
|
|
|
|
|
+ async start() {
|
|
|
|
|
+ try {
|
|
|
|
|
+ this._buffer = '';
|
|
|
|
|
+ this._lastWeight = null;
|
|
|
|
|
+ this._stableCount = 0;
|
|
|
|
|
+
|
|
|
|
|
+ await this._openAdapter();
|
|
|
|
|
+ const deviceId = await this._scanDevice();
|
|
|
|
|
+ await this._connect(deviceId);
|
|
|
|
|
+ await this._getService();
|
|
|
|
|
+ await this._getCharacteristic();
|
|
|
|
|
+ await this._subscribeNotify();
|
|
|
|
|
+ this._setStatus('已连接,等待读数...');
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ this._setStatus(err.message);
|
|
|
|
|
+ this.stop();
|
|
|
|
|
+ throw err;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 断开并清理
|
|
|
|
|
+ */
|
|
|
|
|
+ stop() {
|
|
|
|
|
+ // 关闭 notify
|
|
|
|
|
+ if (this._connected && this._deviceId && this._serviceId && this._characteristicId) {
|
|
|
|
|
+ wx.notifyBLECharacteristicValueChange({
|
|
|
|
|
+ deviceId: this._deviceId,
|
|
|
|
|
+ serviceId: this._serviceId,
|
|
|
|
|
+ characteristicId: this._characteristicId,
|
|
|
|
|
+ state: false,
|
|
|
|
|
+ fail: () => {}
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ if (this._deviceId) {
|
|
|
|
|
+ wx.closeBLEConnection({ deviceId: this._deviceId, fail: () => {} });
|
|
|
|
|
+ }
|
|
|
|
|
+ if (this._scanning) {
|
|
|
|
|
+ wx.stopBluetoothDevicesDiscovery();
|
|
|
|
|
+ }
|
|
|
|
|
+ wx.offBLECharacteristicValueChange && wx.offBLECharacteristicValueChange();
|
|
|
|
|
+ this._deviceId = null;
|
|
|
|
|
+ this._serviceId = null;
|
|
|
|
|
+ this._characteristicId = null;
|
|
|
|
|
+ this._connected = false;
|
|
|
|
|
+ this._scanning = false;
|
|
|
|
|
+ this._buffer = '';
|
|
|
|
|
+ this._lastWeight = null;
|
|
|
|
|
+ this._stableCount = 0;
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export default BluetoothScale;
|