1052 lines
25 KiB
JavaScript
1052 lines
25 KiB
JavaScript
/**
|
||
* 蓝牙称重设备服务模块(新版:支持智能数据发送与健壮连接管理)
|
||
* 功能:
|
||
* - 自动连接已配置的称重设备
|
||
* - 主动读取重量:发送 'R' 命令,设备返回一次重量数据(readWeight方法)
|
||
* - 实时监听并解析重量数据(支持ASCII/二进制协议)
|
||
* - 发送控制命令(去皮、校准、单位切换等)
|
||
* - 连接状态管理与事件通知
|
||
* - 支持从 Vuex 或 localStorage 加载配置
|
||
*
|
||
* 使用方法:
|
||
* import scaleService from '@/utils/BluetoothScaleService.new.js';
|
||
* await scaleService.autoConnect();
|
||
*
|
||
* // 主动读取重量(发送一次命令,返回一次数据)
|
||
* const result = await scaleService.readWeight();
|
||
* console.log(result.raw); // 十六进制原始数据
|
||
* console.log(result.rawAscii); // ASCII原始数据
|
||
* console.log(result.value); // 解析后的重量值
|
||
*
|
||
* // 监听重量变化(设备主动推送模式)
|
||
* scaleService.onWeightChange(this.handleWeightChange);
|
||
*/
|
||
|
||
let store = null;
|
||
try {
|
||
store = require('@/store/index.js').default;
|
||
} catch (e) {
|
||
console.warn('[称重服务] Vuex store 加载失败,将使用 localStorage');
|
||
}
|
||
|
||
class BluetoothScaleService {
|
||
constructor() {
|
||
this.adapterOpen = false;
|
||
this.isConnected = false;
|
||
this.connecting = false;
|
||
this.deviceId = null;
|
||
this.deviceName = '';
|
||
this.serviceId = null;
|
||
this.notifyCharId = null;
|
||
this.writeCharId = null;
|
||
|
||
this.pendingRead = null;
|
||
this.readTimeout = 5000;
|
||
|
||
this.connectionChangeCallbacks = [];
|
||
this.weightChangeCallbacks = [];
|
||
this.errorCallbacks = [];
|
||
this.logCallbacks = [];
|
||
|
||
this._connectionListener = null;
|
||
this._dataListener = null;
|
||
|
||
this._writeType = null;
|
||
this._mtu = 20;
|
||
|
||
this.currentWeight = {
|
||
value: 0,
|
||
unit: 'kg',
|
||
stable: false,
|
||
raw: null,
|
||
timestamp: null
|
||
};
|
||
|
||
this.protocolType = 'auto';
|
||
|
||
this.protocols = {
|
||
ascii: {
|
||
pattern: /^([+-]?\d+\.?\d*)\s*(kg|g|lb|oz)?$/,
|
||
parser: (str) => {
|
||
const match = str.match(/([+-]?\d+\.?\d*)/);
|
||
const unitMatch = str.match(/(kg|g|lb|oz)/i);
|
||
return {
|
||
value: match ? parseFloat(match[1]) : null,
|
||
unit: unitMatch ? unitMatch[1].toLowerCase() : 'kg'
|
||
};
|
||
}
|
||
},
|
||
A12: {
|
||
parser: (buffer) => {
|
||
if (!buffer || buffer.length < 5) return null;
|
||
const data = new Uint8Array(buffer);
|
||
const sign = (data[0] & 0x80) ? -1 : 1;
|
||
const stable = (data[0] & 0x40) !== 0;
|
||
const decimal = (data[0] & 0x30) >> 4;
|
||
const weight = ((data[1] << 8) | data[2]) & 0x3FFF;
|
||
const value = sign * weight / Math.pow(10, decimal);
|
||
const units = ['kg', 'g', 'lb', 'oz'];
|
||
const unitIndex = data[3] & 0x03;
|
||
return {
|
||
value: value,
|
||
unit: units[unitIndex] || 'kg',
|
||
stable: stable
|
||
};
|
||
}
|
||
},
|
||
Sartorius: {
|
||
parser: (buffer) => {
|
||
if (!buffer || buffer.length < 6) return null;
|
||
const data = new Uint8Array(buffer);
|
||
const value = ((data[2] << 16) | (data[3] << 8) | data[4]) / 1000;
|
||
return {
|
||
value: value,
|
||
unit: 'kg',
|
||
stable: data[1] === 0x01
|
||
};
|
||
}
|
||
},
|
||
Mettler: {
|
||
parser: (buffer) => {
|
||
if (!buffer || buffer.length < 8) return null;
|
||
const data = new Uint8Array(buffer);
|
||
const value = parseFloat(String.fromCharCode.apply(null, data.slice(1, 7)));
|
||
return {
|
||
value: value,
|
||
unit: 'kg',
|
||
stable: data[7] === 0x0D
|
||
};
|
||
}
|
||
}
|
||
};
|
||
|
||
this.commands = {
|
||
// 读取重量命令:0x52 = ASCII 'R'
|
||
READ_WEIGHT: [0x52],
|
||
TARE: [0x1B, 0x54],
|
||
CALIBRATE_1KG: [0x1B, 0x43, 0x01],
|
||
CALIBRATE_2KG: [0x1B, 0x43, 0x02],
|
||
CALIBRATE_5KG: [0x1B, 0x43, 0x03],
|
||
UNIT_KG: [0x1B, 0x55, 0x01],
|
||
UNIT_G: [0x1B, 0x55, 0x02],
|
||
UNIT_LB: [0x1B, 0x55, 0x03],
|
||
ZERO: [0x1B, 0x5A],
|
||
RESET: [0x1B, 0x52]
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 初始化蓝牙适配器
|
||
*/
|
||
async init() {
|
||
if (this.adapterOpen) return;
|
||
|
||
return new Promise((resolve, reject) => {
|
||
uni.openBluetoothAdapter({
|
||
success: () => {
|
||
this.adapterOpen = true;
|
||
this._registerListeners();
|
||
resolve();
|
||
},
|
||
fail: (err) => {
|
||
console.error('[称重服务] 蓝牙初始化失败', err);
|
||
reject(err);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 注册蓝牙事件监听
|
||
*/
|
||
_registerListeners() {
|
||
if (this._connectionListener) return;
|
||
|
||
this._connectionListener = (res) => {
|
||
if (!res.connected && this.deviceId === res.deviceId) {
|
||
this.isConnected = false;
|
||
this._log('设备连接断开');
|
||
this._notifyConnectionChange(false);
|
||
}
|
||
};
|
||
|
||
this._dataListener = (res) => {
|
||
if (res.deviceId === this.deviceId) {
|
||
this._handleDataReceived(res.value);
|
||
}
|
||
};
|
||
|
||
uni.onBLEConnectionStateChange(this._connectionListener);
|
||
uni.onBLECharacteristicValueChange(this._dataListener);
|
||
}
|
||
|
||
/**
|
||
* 销毁,清理资源
|
||
*/
|
||
destroy() {
|
||
if (this._connectionListener) {
|
||
try {
|
||
uni.offBLEConnectionStateChange(this._connectionListener);
|
||
} catch (e) {
|
||
console.warn('[称重服务] 移除连接监听失败', e);
|
||
}
|
||
this._connectionListener = null;
|
||
}
|
||
|
||
if (this._dataListener) {
|
||
try {
|
||
uni.offBLECharacteristicValueChange(this._dataListener);
|
||
} catch (e) {
|
||
console.warn('[称重服务] 移除数据监听失败', e);
|
||
}
|
||
this._dataListener = null;
|
||
}
|
||
|
||
if (this.pendingRead) {
|
||
clearTimeout(this.pendingRead.timer);
|
||
this.pendingRead.reject(new Error('服务已销毁'));
|
||
this.pendingRead = null;
|
||
}
|
||
|
||
this.disconnect();
|
||
}
|
||
|
||
/**
|
||
* 处理接收到的数据
|
||
*/
|
||
_handleDataReceived(buffer) {
|
||
try {
|
||
if (this.pendingRead) {
|
||
const pending = this.pendingRead;
|
||
this.pendingRead = null;
|
||
clearTimeout(pending.timer);
|
||
|
||
const rawHex = this._bufferToHex(buffer);
|
||
const rawAscii = this._bufferToString(buffer);
|
||
this._log(`读取响应 - HEX: ${rawHex}, ASCII: ${rawAscii}`);
|
||
|
||
try {
|
||
const weight = this._parseCustom(buffer);
|
||
pending.resolve({
|
||
raw: rawHex,
|
||
rawAscii: rawAscii,
|
||
...weight
|
||
});
|
||
} catch (parseError) {
|
||
this._log(`解析响应失败: ${parseError.message}`, 'error');
|
||
pending.resolve({
|
||
raw: rawHex,
|
||
rawAscii: rawAscii,
|
||
value: null,
|
||
unit: 'kg',
|
||
stable: false,
|
||
rawHex: rawHex,
|
||
rawAscii: rawAscii.trim()
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
|
||
let weight = null;
|
||
|
||
if (this.protocolType === 'auto') {
|
||
weight = this._autoParse(buffer);
|
||
} else if (this.protocolType === 'ascii') {
|
||
weight = this._parseAscii(buffer);
|
||
} else {
|
||
weight = this._parseBinary(buffer);
|
||
}
|
||
|
||
if (weight) {
|
||
this.currentWeight = {
|
||
...weight,
|
||
raw: this._bufferToHex(buffer),
|
||
timestamp: new Date().toISOString()
|
||
};
|
||
|
||
this._log(`重量: ${weight.value} ${weight.unit} ${weight.stable ? '(稳定)' : '(变化中)'}`);
|
||
|
||
this._notifyWeightChange(this.currentWeight);
|
||
}
|
||
} catch (error) {
|
||
this._log(`数据解析错误: ${error.message}`, 'error');
|
||
this._notifyError(error);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 自动检测并解析数据格式
|
||
*/
|
||
_autoParse(buffer) {
|
||
const asciiResult = this._parseAscii(buffer);
|
||
if (asciiResult) {
|
||
return asciiResult;
|
||
}
|
||
return this._parseBinary(buffer);
|
||
}
|
||
|
||
/**
|
||
* 解析ASCII格式数据
|
||
*/
|
||
_parseAscii(buffer) {
|
||
try {
|
||
const text = this._bufferToString(buffer);
|
||
const trimmed = text.trim();
|
||
|
||
const match = trimmed.match(/^([+-]?\d+\.?\d*)\s*(kg|g|lb|oz)?$/i);
|
||
if (match) {
|
||
return {
|
||
value: parseFloat(match[1]),
|
||
unit: match[2] ? match[2].toLowerCase() : 'kg',
|
||
stable: true
|
||
};
|
||
}
|
||
|
||
const statusMatch = trimmed.match(/^(ST|US|OL),?([+-]?\d+\.?\d*)\s*(kg|g|lb|oz)?$/i);
|
||
if (statusMatch) {
|
||
return {
|
||
value: parseFloat(statusMatch[2]),
|
||
unit: statusMatch[3] ? statusMatch[3].toLowerCase() : 'kg',
|
||
stable: statusMatch[1].toUpperCase() === 'ST'
|
||
};
|
||
}
|
||
|
||
// 自定义格式: wn0000.00kg (wn前缀 + 数字 + 单位)
|
||
const customMatch = trimmed.match(/^[a-zA-Z]*([+-]?\d+\.?\d*)(kg|g|lb|oz)$/i);
|
||
if (customMatch) {
|
||
return {
|
||
value: parseFloat(customMatch[1]),
|
||
unit: customMatch[2].toLowerCase(),
|
||
stable: true
|
||
};
|
||
}
|
||
|
||
return null;
|
||
} catch (error) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 解析二进制格式数据
|
||
*/
|
||
_parseBinary(buffer) {
|
||
for (const [name, protocol] of Object.entries(this.protocols)) {
|
||
if (name === 'ascii') continue;
|
||
try {
|
||
const result = protocol.parser(buffer);
|
||
if (result && typeof result.value === 'number' && !isNaN(result.value)) {
|
||
return result;
|
||
}
|
||
} catch (e) {
|
||
}
|
||
}
|
||
|
||
return this.protocols.A12.parser(buffer);
|
||
}
|
||
|
||
/**
|
||
* 自定义协议解析(主动读取模式使用)
|
||
*/
|
||
_parseCustom(buffer) {
|
||
const data = new Uint8Array(buffer);
|
||
const hexStr = this._bufferToHex(buffer);
|
||
const asciiStr = this._bufferToString(buffer);
|
||
|
||
const autoResult = this._autoParse(buffer);
|
||
if (autoResult && autoResult.value !== null && !isNaN(autoResult.value)) {
|
||
this._log(`[自定义协议] 自动解析成功: ${autoResult.value} ${autoResult.unit}`);
|
||
return {
|
||
...autoResult,
|
||
rawHex: hexStr,
|
||
rawAscii: asciiStr.trim()
|
||
};
|
||
}
|
||
|
||
this._log(`[自定义协议] 未识别的格式 - HEX: ${hexStr}, ASCII: ${asciiStr.trim()}`);
|
||
return {
|
||
value: null,
|
||
unit: 'kg',
|
||
stable: false,
|
||
rawHex: hexStr,
|
||
rawAscii: asciiStr.trim()
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Buffer转16进制字符串
|
||
*/
|
||
_bufferToHex(buffer) {
|
||
const data = new Uint8Array(buffer);
|
||
return Array.from(data).map(b => b.toString(16).padStart(2, '0')).join(' ');
|
||
}
|
||
|
||
/**
|
||
* Buffer转字符串
|
||
*/
|
||
_bufferToString(buffer) {
|
||
const data = new Uint8Array(buffer);
|
||
return String.fromCharCode.apply(null, data);
|
||
}
|
||
|
||
/**
|
||
* 设置协议类型
|
||
*/
|
||
setProtocol(protocol) {
|
||
if (protocol === 'auto' || protocol === 'ascii' || this.protocols[protocol]) {
|
||
this.protocolType = protocol;
|
||
this._log(`协议已切换: ${protocol}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取当前配置
|
||
*/
|
||
getConfig() {
|
||
return {
|
||
deviceId: this.deviceId,
|
||
deviceName: this.deviceName,
|
||
serviceId: this.serviceId,
|
||
notifyCharId: this.notifyCharId,
|
||
writeCharId: this.writeCharId,
|
||
protocol: this.protocolType,
|
||
isConnected: this.isConnected
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 从存储加载配置(优先从 Vuex,其次从 localStorage)
|
||
*/
|
||
loadConfig() {
|
||
let scaleConfig = {};
|
||
|
||
if (store) {
|
||
try {
|
||
const state = store.state.scale;
|
||
if (state && state.scaleConfig) {
|
||
scaleConfig = state.scaleConfig;
|
||
this._log('配置从 Vuex 加载');
|
||
}
|
||
} catch (e) {
|
||
this._log(`Vuex 加载配置失败: ${e.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
if (!scaleConfig.deviceId) {
|
||
const storageConfig = uni.getStorageSync('scaleConfig');
|
||
if (storageConfig) {
|
||
scaleConfig = storageConfig;
|
||
this._log('配置从 localStorage 加载');
|
||
}
|
||
}
|
||
|
||
if (scaleConfig) {
|
||
this.deviceId = scaleConfig.deviceId || null;
|
||
this.deviceName = scaleConfig.deviceName || '';
|
||
this.serviceId = scaleConfig.serviceId || null;
|
||
this.notifyCharId = scaleConfig.characteristicId || null;
|
||
this.writeCharId = scaleConfig.writeCharacteristicId || null;
|
||
}
|
||
|
||
return this.getConfig();
|
||
}
|
||
|
||
/**
|
||
* 自动连接已配置的设备
|
||
*/
|
||
async autoConnect() {
|
||
if (this.connecting || this.isConnected) {
|
||
return this.isConnected;
|
||
}
|
||
|
||
this.loadConfig();
|
||
|
||
// 调试日志:显示加载的配置
|
||
this._log(`配置信息: deviceId=${this.deviceId}, serviceId=${this.serviceId}, notifyCharId=${this.notifyCharId}, writeCharId=${this.writeCharId}`);
|
||
|
||
if (!this.deviceId || !this.serviceId) {
|
||
throw new Error('未配置称重设备,请先在设置中配置');
|
||
}
|
||
|
||
return this.connect();
|
||
}
|
||
|
||
/**
|
||
* 获取已连接的蓝牙设备列表
|
||
*/
|
||
_getConnectedDevices() {
|
||
return new Promise((resolve) => {
|
||
uni.getConnectedBluetoothDevices({
|
||
success: (res) => resolve(res.devices || []),
|
||
fail: () => resolve([])
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 连接指定设备
|
||
*/
|
||
async connect() {
|
||
if (this.connecting || this.isConnected) {
|
||
return this.isConnected;
|
||
}
|
||
|
||
if (!this.deviceId) {
|
||
throw new Error('缺少设备ID');
|
||
}
|
||
|
||
this.connecting = true;
|
||
|
||
try {
|
||
await this.init();
|
||
|
||
// 检查设备是否已经连接(避免重复连接报错)
|
||
const connectedDevices = await this._getConnectedDevices();
|
||
const isAlreadyConnected = connectedDevices.some(d => d.deviceId === this.deviceId);
|
||
|
||
if (isAlreadyConnected) {
|
||
this._log('设备已连接,跳过创建连接');
|
||
} else {
|
||
this._log(`正在连接设备: ${this.deviceName || this.deviceId}`);
|
||
|
||
await new Promise((resolve, reject) => {
|
||
uni.createBLEConnection({
|
||
deviceId: this.deviceId,
|
||
timeout: 15000,
|
||
success: resolve,
|
||
fail: reject
|
||
});
|
||
});
|
||
}
|
||
|
||
try {
|
||
const mtuRes = await new Promise((res) => {
|
||
uni.setBLEMTU({
|
||
deviceId: this.deviceId,
|
||
mtu: 512,
|
||
success: res,
|
||
fail: () => res({ mtu: 23 })
|
||
});
|
||
});
|
||
this._mtu = (mtuRes.mtu || 512) - 3;
|
||
this._log(`MTU 协商: 请求 512, 实际=${mtuRes.mtu}, 写入块=${this._mtu}`);
|
||
} catch (e) {
|
||
this._log(`MTU 协商异常, 使用默认值 20: ${e.message}`, 'error');
|
||
this._mtu = 20;
|
||
}
|
||
|
||
await this._delay(800);
|
||
|
||
if (this.notifyCharId) {
|
||
try {
|
||
await new Promise((resolve, reject) => {
|
||
uni.notifyBLECharacteristicValueChange({
|
||
deviceId: this.deviceId,
|
||
serviceId: this.serviceId,
|
||
characteristicId: this.notifyCharId,
|
||
state: true,
|
||
success: resolve,
|
||
fail: reject
|
||
});
|
||
});
|
||
this._log(`通知已启用: ${this.notifyCharId}`);
|
||
} catch (e) {
|
||
this._log(`启用通知失败: ${e.message}`, 'error');
|
||
}
|
||
} else {
|
||
this._log(`未配置通知特征值,无法接收数据`, 'warn');
|
||
}
|
||
|
||
this.isConnected = true;
|
||
this.connecting = false;
|
||
|
||
this._log('连接成功');
|
||
this._notifyConnectionChange(true);
|
||
|
||
return true;
|
||
} catch (error) {
|
||
this._log(`连接失败: ${error.message}`, 'error');
|
||
this.isConnected = false;
|
||
this.connecting = false;
|
||
|
||
this._notifyError(error);
|
||
this._notifyConnectionChange(false);
|
||
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 断开连接
|
||
*/
|
||
async disconnect() {
|
||
if (this.pendingRead) {
|
||
clearTimeout(this.pendingRead.timer);
|
||
this.pendingRead.reject(new Error('连接已断开'));
|
||
this.pendingRead = null;
|
||
}
|
||
|
||
if (this.deviceId) {
|
||
try {
|
||
await new Promise((resolve, reject) => {
|
||
uni.closeBLEConnection({
|
||
deviceId: this.deviceId,
|
||
success: resolve,
|
||
fail: reject
|
||
});
|
||
});
|
||
} catch (e) {
|
||
this._log(`断开连接失败: ${e.message}`, 'error');
|
||
}
|
||
}
|
||
|
||
this.isConnected = false;
|
||
this._log('已断开连接');
|
||
this._notifyConnectionChange(false);
|
||
}
|
||
|
||
/**
|
||
* 发送命令
|
||
*/
|
||
async sendCommand(command) {
|
||
if (!this.isConnected) {
|
||
throw new Error('设备未连接');
|
||
}
|
||
|
||
if (!this.writeCharId) {
|
||
this._log(`未配置写入特征值,无法发送命令`, 'error');
|
||
throw new Error('未配置写入特征值');
|
||
}
|
||
|
||
let cmdData;
|
||
if (Array.isArray(command)) {
|
||
cmdData = new Uint8Array(command);
|
||
} else if (this.commands[command]) {
|
||
// 从命令表获取(如 'READ_WEIGHT' → [0x52])
|
||
cmdData = new Uint8Array(this.commands[command]);
|
||
} else {
|
||
throw new Error('未知的命令: ' + command);
|
||
}
|
||
|
||
// 显示 HEX 和 ASCII
|
||
const hexStr = Array.from(cmdData).map(b => b.toString(16).padStart(2, '0')).join(' ');
|
||
const asciiStr = String.fromCharCode(...cmdData).replace(/[^\x20-\x7E]/g, '?');
|
||
this._log(`发送命令: "${asciiStr}" (HEX: ${hexStr})`);
|
||
|
||
try {
|
||
await this._writeData(cmdData.buffer);
|
||
return true;
|
||
} catch (error) {
|
||
this._log(`命令发送失败: ${error.message}`, 'error');
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 写入数据到蓝牙设备(自动适配 MTU 和 writeType)
|
||
*/
|
||
async _writeData(buffer, attempt = 0) {
|
||
const dataLen = buffer.byteLength;
|
||
const mtu = this._mtu || 20;
|
||
|
||
if (dataLen > mtu) {
|
||
this._log(`数据 ${dataLen}B > MTU ${mtu}B, 自动分片发送`);
|
||
return this._writeDataInChunks(buffer, mtu);
|
||
}
|
||
|
||
const writeTypes = [null, 'writeNoResponse', 'write'];
|
||
const maxAttempts = 3;
|
||
|
||
if (attempt >= maxAttempts) {
|
||
this._log(`写入重试 ${maxAttempts} 次仍失败,视为尾包/打印机忙导致`, 'error');
|
||
return { success: true, silent: true };
|
||
}
|
||
|
||
const writeType = writeTypes[attempt] || null;
|
||
|
||
const options = {
|
||
deviceId: this.deviceId,
|
||
serviceId: this.serviceId,
|
||
characteristicId: this.writeCharId,
|
||
value: buffer
|
||
};
|
||
|
||
if (writeType) {
|
||
options.writeType = writeType;
|
||
}
|
||
|
||
return new Promise((resolve, reject) => {
|
||
uni.writeBLECharacteristicValue({
|
||
...options,
|
||
success: (res) => {
|
||
this._writeType = writeType;
|
||
this._log(`写入成功, writeType=${writeType || 'auto'}`);
|
||
resolve(res);
|
||
},
|
||
fail: (err) => {
|
||
this._log(`写入失败 attempt=${attempt + 1}, errCode=${err.errCode}, msg=${err.errMsg}`, 'error');
|
||
|
||
const delay = 100 + attempt * 150;
|
||
setTimeout(() => {
|
||
this._writeData(buffer, attempt + 1).then(resolve).catch(reject);
|
||
}, delay);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 分片发送数据
|
||
*/
|
||
async _writeDataInChunks(buffer, mtu = null) {
|
||
const totalLength = buffer.byteLength;
|
||
mtu = mtu || this._mtu || 20;
|
||
|
||
const dataView = new Uint8Array(buffer);
|
||
|
||
for (let offset = 0; offset < totalLength; offset += mtu) {
|
||
const end = Math.min(offset + mtu, totalLength);
|
||
const chunk = dataView.slice(offset, end);
|
||
const chunkBuffer = chunk.buffer.slice(chunk.byteOffset, chunk.byteOffset + chunk.byteLength);
|
||
const isLastChunk = end >= totalLength;
|
||
|
||
try {
|
||
await this._writeChunk(chunkBuffer);
|
||
|
||
if (offset + mtu < totalLength) {
|
||
await this._delay(50);
|
||
}
|
||
} catch (err) {
|
||
if (isLastChunk) {
|
||
const retryDelays = [300, 600, 1000];
|
||
let delivered = false;
|
||
for (let attempt = 0; attempt < retryDelays.length; attempt++) {
|
||
const wait = retryDelays[attempt];
|
||
this._log(`尾包发送失败,第 ${attempt + 1}/${retryDelays.length} 次重试,等待 ${wait}ms...`, 'error');
|
||
await this._delay(wait);
|
||
try {
|
||
await this._writeChunkWithResponse(chunkBuffer);
|
||
this._log(`尾包重试成功`);
|
||
delivered = true;
|
||
break;
|
||
} catch (retryErr) {
|
||
this._log(`尾包第 ${attempt + 1} 次重试仍失败: ${retryErr.message}`, 'error');
|
||
}
|
||
}
|
||
if (delivered) {
|
||
continue;
|
||
}
|
||
this._log(`尾包 3 次重试均失败,数据可能不完整`, 'error');
|
||
return;
|
||
}
|
||
this._log(`分片发送失败 at offset ${offset}: ${err.message}`, 'error');
|
||
throw err;
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发送单个数据包(分片用)
|
||
*/
|
||
_writeChunk(chunkBuffer, retryWithWrite = false, attempt = 0) {
|
||
return new Promise((resolve, reject) => {
|
||
uni.writeBLECharacteristicValue({
|
||
deviceId: this.deviceId,
|
||
serviceId: this.serviceId,
|
||
characteristicId: this.writeCharId,
|
||
value: chunkBuffer,
|
||
writeType: retryWithWrite ? 'write' : 'writeNoResponse',
|
||
success: resolve,
|
||
fail: async (err) => {
|
||
if (!retryWithWrite && err.errCode === 10007) {
|
||
this._log('writeNoResponse 不支持,降级为 write 模式', 'error');
|
||
try {
|
||
await this._delay(50);
|
||
await this._writeChunk(chunkBuffer, true, 0);
|
||
resolve();
|
||
} catch (downgradeErr) {
|
||
reject(downgradeErr);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (err.errCode === 10007 && attempt < 3) {
|
||
const wait = 100 * (attempt + 1);
|
||
this._log(`设备忙(10007),${wait}ms 后第 ${attempt + 1}/3 次重试...`, 'error');
|
||
try {
|
||
await this._delay(wait);
|
||
await this._writeChunk(chunkBuffer, retryWithWrite, attempt + 1);
|
||
resolve();
|
||
} catch (retryErr) {
|
||
reject(retryErr);
|
||
}
|
||
return;
|
||
}
|
||
reject(err);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 发送单个数据包 —— 强制使用 'write' 模式(带响应)
|
||
*/
|
||
_writeChunkWithResponse(chunkBuffer) {
|
||
return new Promise((resolve, reject) => {
|
||
uni.writeBLECharacteristicValue({
|
||
deviceId: this.deviceId,
|
||
serviceId: this.serviceId,
|
||
characteristicId: this.writeCharId,
|
||
value: chunkBuffer,
|
||
writeType: 'write',
|
||
success: resolve,
|
||
fail: reject
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 主动读取重量
|
||
*/
|
||
async readWeight(timeout) {
|
||
if (!this.isConnected) {
|
||
throw new Error('设备未连接');
|
||
}
|
||
|
||
if (this.pendingRead) {
|
||
throw new Error('已有读取请求等待中,请勿重复发送');
|
||
}
|
||
|
||
const actualTimeout = timeout || this.readTimeout;
|
||
|
||
return new Promise(async (resolve, reject) => {
|
||
const timer = setTimeout(() => {
|
||
this.pendingRead = null;
|
||
reject(new Error(`读取重量超时(${actualTimeout}ms),设备未返回数据`));
|
||
}, actualTimeout);
|
||
|
||
this.pendingRead = { resolve, reject, timer };
|
||
|
||
try {
|
||
await this.sendCommand('READ_WEIGHT');
|
||
this._log('已发送读取重量命令,等待设备响应...');
|
||
} catch (error) {
|
||
clearTimeout(timer);
|
||
this.pendingRead = null;
|
||
reject(error);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 去皮(归零)
|
||
*/
|
||
async tare() {
|
||
return this.sendCommand('TARE');
|
||
}
|
||
|
||
/**
|
||
* 砝码校准
|
||
* @param {number} weight 校准重量(默认1kg)
|
||
*/
|
||
async calibrate(weight = 1) {
|
||
let command;
|
||
if (weight <= 1) {
|
||
command = 'CALIBRATE_1KG';
|
||
} else if (weight <= 2) {
|
||
command = 'CALIBRATE_2KG';
|
||
} else {
|
||
command = 'CALIBRATE_5KG';
|
||
}
|
||
return this.sendCommand(command);
|
||
}
|
||
|
||
/**
|
||
* 切换单位
|
||
* @param {string} unit kg, g, lb
|
||
*/
|
||
async changeUnit(unit) {
|
||
const unitMap = {
|
||
'kg': 'UNIT_KG',
|
||
'g': 'UNIT_G',
|
||
'lb': 'UNIT_LB'
|
||
};
|
||
const command = unitMap[unit];
|
||
if (!command) {
|
||
throw new Error('不支持的单位');
|
||
}
|
||
return this.sendCommand(command);
|
||
}
|
||
|
||
/**
|
||
* 获取当前重量
|
||
*/
|
||
getCurrentWeight() {
|
||
return { ...this.currentWeight };
|
||
}
|
||
|
||
/**
|
||
* 获取实时重量(带稳定性等待)
|
||
*/
|
||
async getStableWeight(timeout = 10000) {
|
||
return new Promise((resolve, reject) => {
|
||
const startTime = Date.now();
|
||
|
||
const checkWeight = () => {
|
||
if (this.currentWeight.stable) {
|
||
resolve(this.currentWeight);
|
||
} else if (Date.now() - startTime > timeout) {
|
||
reject(new Error('等待重量稳定超时'));
|
||
} else {
|
||
setTimeout(checkWeight, 100);
|
||
}
|
||
};
|
||
|
||
checkWeight();
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 发送自定义命令
|
||
*/
|
||
async sendRawCommand(commandArray) {
|
||
return this.sendCommand(commandArray);
|
||
}
|
||
|
||
/**
|
||
* 注册重量变化回调
|
||
*/
|
||
onWeightChange(callback) {
|
||
if (!this.weightChangeCallbacks.includes(callback)) {
|
||
this.weightChangeCallbacks.push(callback);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取消重量变化回调
|
||
*/
|
||
offWeightChange(callback) {
|
||
const index = this.weightChangeCallbacks.indexOf(callback);
|
||
if (index > -1) {
|
||
this.weightChangeCallbacks.splice(index, 1);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 注册连接状态回调
|
||
*/
|
||
onConnectionChange(callback) {
|
||
if (!this.connectionChangeCallbacks.includes(callback)) {
|
||
this.connectionChangeCallbacks.push(callback);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取消连接状态回调
|
||
*/
|
||
offConnectionChange(callback) {
|
||
const index = this.connectionChangeCallbacks.indexOf(callback);
|
||
if (index > -1) {
|
||
this.connectionChangeCallbacks.splice(index, 1);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 注册错误回调
|
||
*/
|
||
onError(callback) {
|
||
if (!this.errorCallbacks.includes(callback)) {
|
||
this.errorCallbacks.push(callback);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取消错误回调
|
||
*/
|
||
offError(callback) {
|
||
const index = this.errorCallbacks.indexOf(callback);
|
||
if (index > -1) {
|
||
this.errorCallbacks.splice(index, 1);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 注册日志回调
|
||
*/
|
||
onLog(callback) {
|
||
if (!this.logCallbacks.includes(callback)) {
|
||
this.logCallbacks.push(callback);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 取消日志回调
|
||
*/
|
||
offLog(callback) {
|
||
const index = this.logCallbacks.indexOf(callback);
|
||
if (index > -1) {
|
||
this.logCallbacks.splice(index, 1);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 通知重量变化
|
||
*/
|
||
_notifyWeightChange(weight) {
|
||
this.weightChangeCallbacks.forEach(cb => {
|
||
try {
|
||
cb(weight);
|
||
} catch (e) {
|
||
this._log(`重量变化回调执行失败: ${e.message}`, 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 通知连接状态变化
|
||
*/
|
||
_notifyConnectionChange(connected) {
|
||
this.connectionChangeCallbacks.forEach(cb => {
|
||
try {
|
||
cb(connected);
|
||
} catch (e) {
|
||
this._log(`连接状态回调执行失败: ${e.message}`, 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 通知错误
|
||
*/
|
||
_notifyError(error) {
|
||
this.errorCallbacks.forEach(cb => {
|
||
try {
|
||
cb(error);
|
||
} catch (e) {
|
||
this._log(`错误回调执行失败: ${e.message}`, 'error');
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 记录日志
|
||
*/
|
||
_log(message, level = 'info') {
|
||
console[level === 'error' ? 'error' : 'log'](`[称重服务] ${message}`);
|
||
this.logCallbacks.forEach(cb => {
|
||
try {
|
||
cb(message, level);
|
||
} catch (e) {
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 延迟
|
||
*/
|
||
_delay(ms) {
|
||
return new Promise(resolve => setTimeout(resolve, ms));
|
||
}
|
||
}
|
||
|
||
export default new BluetoothScaleService(); |