Files
uni-pda/utils/BluetoothScaleService.js
2026-06-02 15:28:25 +08:00

704 lines
18 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 蓝牙称重设备服务模块
* 功能:
* - 自动连接已配置的称重设备
* - 主动读取重量:发送 'R' 命令,设备返回一次重量数据(readWeight方法)
* - 实时监听并解析重量数据(支持ASCII/二进制协议)
* - 发送控制命令(去皮、校准、单位切换等)
* - 连接状态管理与事件通知
*
* 使用方法:
* import scaleService from '@/utils/BluetoothScaleService.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);
*/
import bluetoothManager from './BluetoothManager.js';
class BluetoothScaleService {
constructor() {
this.isConnected = false;
this.deviceId = null;
this.deviceName = '';
this.serviceId = null;
this.notifyCharId = null;
this.writeCharId = null;
// 主动读取等待状态(发送读取命令后等待设备返回数据)
this.pendingRead = null; // { resolve, reject, timer }
this.readTimeout = 5000; // 读取超时时间(ms)
// 回调函数
this.callbacks = {
onWeightChange: null,
onConnectionChange: null,
onError: null,
onLog: null
};
// 当前重量数据
this.currentWeight = {
value: 0,
unit: 'kg',
stable: false,
raw: null,
timestamp: null
};
// 数据协议类型
this.protocolType = 'auto'; // auto, ascii, binary
// 预设的称重设备协议
this.protocols = {
// 通用ASCII协议(直接解析数字字符串)
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等常见电子秤)
A12: {
// 协议格式: 0x05 + 整数部分 + 小数部分 + 单位
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 = {
READ_WEIGHT: [0x52], // 'R' - 读取重量命令(ASCII 'R'
TARE: [0x1B, 0x54], // 去皮命令
CALIBRATE_1KG: [0x1B, 0x43, 0x01], // 1kg校准
CALIBRATE_2KG: [0x1B, 0x43, 0x02], // 2kg校准
CALIBRATE_5KG: [0x1B, 0x43, 0x03], // 5kg校准
UNIT_KG: [0x1B, 0x55, 0x01], // 切换kg
UNIT_G: [0x1B, 0x55, 0x02], // 切换g
UNIT_LB: [0x1B, 0x55, 0x03], // 切换lb
ZERO: [0x1B, 0x5A], // 零位校准
RESET: [0x1B, 0x52] // 复位
};
// 初始化监听器
this._initListeners();
}
/**
* 初始化蓝牙事件监听
*/
_initListeners() {
// 连接状态变化
bluetoothManager.setConnectionChangeCallback((deviceId, connected) => {
if (deviceId === this.deviceId) {
this.isConnected = connected;
if (!connected) {
this._log('设备连接断开');
}
if (this.callbacks.onConnectionChange) {
this.callbacks.onConnectionChange(connected);
}
}
});
// 数据接收
bluetoothManager.setDataReceivedCallback((deviceId, value) => {
if (deviceId === this.deviceId) {
this._handleDataReceived(value);
}
});
}
/**
* 处理接收到的数据
*/
_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}`);
// 解析自定义协议
const weight = this._parseCustom(buffer);
pending.resolve({
raw: rawHex,
rawAscii: rawAscii,
...weight
});
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 ? '(稳定)' : '(变化中)'}`);
if (this.callbacks.onWeightChange) {
this.callbacks.onWeightChange(this.currentWeight);
}
}
} catch (error) {
this._log(`数据解析错误: ${error.message}`, 'error');
}
}
/**
* 自动检测并解析数据格式
*/
_autoParse(buffer) {
// 先尝试ASCII解析
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();
// 匹配常见的重量格式
// 例如: "1.23 kg", "2.500kg", "+5.00 KG", "-0.50 kg"
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
};
}
// 匹配带状态位的格式
// 例如: "ST,1.23kg" 或 "US,2.50kg"
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'
};
}
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) {
// 继续尝试下一个协议
}
}
// 默认二进制解析(A12格式)
return this.protocols.A12.parser(buffer);
}
/**
* 自定义协议解析(主动读取模式使用)
*
* 【重要】此方法用于 readWeight() 主动读取模式,当发送 'R' 命令后
* 设备返回数据时会调用此方法解析。
*
* 当前实现:先尝试自动检测(ASCII/二进制),如果都匹配不上则返回原始数据
* 如果你的设备有特殊协议格式,请取消下方注释并修改解析逻辑
*
* @param {ArrayBuffer} buffer 设备返回的原始数据
* @returns {{ value: number, unit: string, stable: boolean, rawHex: string, rawAscii: string }}
*/
_parseCustom(buffer) {
const data = new Uint8Array(buffer);
const hexStr = this._bufferToHex(buffer);
const asciiStr = this._bufferToString(buffer);
// ============================================================
// 【方式一】先尝试通用自动解析(支持常见ASCII和二进制协议)
// 大部分蓝牙秤可以直接用这个,不需要改下面的代码
// ============================================================
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()
};
}
// ============================================================
// 【方式二】如果你的设备有特殊协议格式,在这里添加解析逻辑
// 取消注释并根据实际数据格式修改
// ============================================================
// --- 示例1: 十六进制帧格式 ---
// 假设设备返回 6 字节: [帧头0xAA][命令0x52][数据高位][数据低位][小数位][校验]
// 例: AA 52 00 7B 02 D1 → 重量 = 0x007B / 10^2 = 1.23 kg
//
// if (data.length >= 6 && data[0] === 0xAA && data[1] === 0x52) {
// const weightRaw = (data[2] << 8) | data[3];
// const decimal = data[4]; // 小数位数
// const value = weightRaw / Math.pow(10, decimal);
// this._log(`[自定义协议] 十六进制解析成功: ${value}`);
// return { value, unit: 'kg', stable: true, rawHex: hexStr, rawAscii: asciiStr.trim() };
// }
// --- 示例2: ASCII带前缀格式 ---
// 假设设备返回: "W:1.23kg\r\n" 或 "+00123g\r\n"
//
// const match = asciiStr.trim().match(/W:([+-]?\d+\.?\d*)\s*(kg|g|lb|oz)?/i);
// if (match) {
// this._log(`[自定义协议] ASCII解析成功: ${match[1]}`);
// return { value: parseFloat(match[1]), unit: (match[2] || 'kg').toLowerCase(), stable: true, rawHex: hexStr, rawAscii: asciiStr.trim() };
// }
// ============================================================
// 以上都没有匹配到 → 返回原始数据(value 为 null
// 页面拿到后可以显示原始数据方便调试
// ============================================================
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
};
}
/**
* 从存储加载配置
*/
loadConfig() {
const scaleConfig = uni.getStorageSync('scaleConfig');
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;
this._log('配置已加载');
}
return this.getConfig();
}
/**
* 自动连接已配置的设备
*/
async autoConnect() {
this.loadConfig();
if (!this.deviceId || !this.serviceId) {
throw new Error('未配置称重设备,请先在设置中配置');
}
return this.connect();
}
/**
* 连接指定设备
*/
async connect() {
if (!this.deviceId) {
throw new Error('缺少设备ID');
}
try {
this._log('正在初始化蓝牙...');
await bluetoothManager.init();
this._log(`正在连接设备: ${this.deviceName || this.deviceId}`);
const result = await bluetoothManager.connectKnownDevice(
this.deviceId,
this.serviceId,
this.writeCharId,
this.notifyCharId
);
// 更新配置
this.serviceId = result.serviceId;
this.notifyCharId = result.notifyCharId;
this.writeCharId = result.writeCharId;
this.isConnected = true;
this._log('连接成功');
// 通知连接状态
if (this.callbacks.onConnectionChange) {
this.callbacks.onConnectionChange(true);
}
return true;
} catch (error) {
this._log(`连接失败: ${error.message}`, 'error');
this.isConnected = false;
if (this.callbacks.onError) {
this.callbacks.onError(error);
}
throw error;
}
}
/**
* 断开连接
*/
async disconnect() {
if (this.deviceId) {
await bluetoothManager.disconnectDevice(this.deviceId);
}
this.isConnected = false;
this._log('已断开连接');
if (this.callbacks.onConnectionChange) {
this.callbacks.onConnectionChange(false);
}
}
/**
* 发送命令
*/
async sendCommand(command) {
if (!this.isConnected) {
throw new Error('设备未连接');
}
let cmdData;
if (Array.isArray(command)) {
cmdData = new Uint8Array(command);
} else if (this.commands[command]) {
cmdData = new Uint8Array(this.commands[command]);
} else {
throw new Error('未知的命令');
}
this._log(`发送命令: ${Array.from(cmdData).map(b => b.toString(16).padStart(2, '0')).join(' ')}`);
try {
await bluetoothManager.writeData(this.deviceId, cmdData.buffer);
return true;
} catch (error) {
this._log(`命令发送失败: ${error.message}`, 'error');
throw error;
}
}
/**
* 主动读取重量
* 向设备发送 'R' 命令,等待设备返回重量数据
* 一次发送一次返回,不会持续读取
*
* @param {number} [timeout=5000] 等待设备响应超时时间(ms)
* @returns {Promise<{raw: string, rawAscii: string, value: number|null, unit: string, stable: boolean}>}
*
* 返回值说明:
* - raw: 设备返回的十六进制字符串,例如 "aa 52 00 7b 02 d1"
* - rawAscii: 设备返回的ASCII字符串,例如 "1.23kg"
* - value: 解析后的重量数值(需在 _parseCustom 中实现解析逻辑,默认为 null)
* - unit: 单位(默认 'kg'
* - stable: 是否稳定(默认 false
*
* 使用示例:
* const result = await scaleService.readWeight();
* console.log(result.raw); // "aa 52 00 7b 02 d1"
* console.log(result.rawAscii); // "1.23kg"
* console.log(result.value); // null(需实现 _parseCustom 后才有值)
*/
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 {
// 发送读取命令 'R'
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) {
this.callbacks.onWeightChange = callback;
}
/**
* 取消重量变化回调
*/
offWeightChange() {
this.callbacks.onWeightChange = null;
}
/**
* 注册连接状态回调
*/
onConnectionChange(callback) {
this.callbacks.onConnectionChange = callback;
}
/**
* 取消连接状态回调
*/
offConnectionChange() {
this.callbacks.onConnectionChange = null;
}
/**
* 注册错误回调
*/
onError(callback) {
this.callbacks.onError = callback;
}
/**
* 取消错误回调
*/
offError() {
this.callbacks.onError = null;
}
/**
* 记录日志
*/
_log(message, level = 'info') {
console[level === 'error' ? 'error' : 'log'](`[称重服务] ${message}`);
if (this.callbacks.onLog) {
this.callbacks.onLog(message, level);
}
}
}
export default new BluetoothScaleService();