/** * 蓝牙打印服务类(新版:支持预设纸张尺寸适配) * 功能: * - 统一管理蓝牙打印机连接 * - 支持 CPCL 和 TSPL 两种指令格式 * - 自动与 Vuex store 同步配置 * - 提供指令生成模板 * - 支持预设纸张尺寸(100x100 / 150x150 / 200x200),自动适配布局 * * 使用方法: * import printerService from '@/utils/BluetoothPrinterService.new.js'; * * // 初始化并连接 * await printerService.init(); * await printerService.autoConnect(); * * // 切换预设纸张 * printerService.usePresetPaperSize('150x150'); * * // 获取所有预设 * const presets = BluetoothPrinterService.getPresetPaperSizes(); * * // 打印操作 * await printerService.printWasteLabel(wasteData); */ import * as iconv from 'iconv-lite'; // 获取 Vuex store 实例(延迟加载避免循环依赖) let store = null; const getStore = () => { if (!store) { store = require('@/store').default; } return store; }; /** * 预设纸张尺寸配置 * 每种尺寸包含 CPCL 和 TSPL 两套布局 */ const PAPER_PRESETS = { '100x100': { labelWidth: 100, labelHeight: 800, fontSize: 28, lineHeight: 45, cpcl: { fields: [ { x: 192, y: 100 }, // 名称 { x: 192, y: 150 }, // 类别 { x: 192, y: 200 }, // 代码 { x: 480, y: 200 }, // 形态 { x: 192, y: 250 }, // 主要成分 { x: 192, y: 350 }, // 有害成分 { x: 192, y: 448 }, // 注意 { x: 216, y: 536 }, // 识别码 { x: 256, y: 584 }, // 单位 { x: 288, y: 632 }, // 联系 { x: 192, y: 680 }, // 日期 { x: 480, y: 680 }, // 重量 { x: 150, y: 728 }, // 备注 { x: 480, y: 448 } // 标识 ], qrCode: { x: 624, y: 584, size: 4 } }, tspl: { startX: 20, startY: 20, lineHeight: 25, fontSize: '4', qrCode: { x: 150, y: 400, size: 5 } } }, '150x150': { labelWidth: 150, labelHeight: 1200, fontSize: 36, lineHeight: 60, cpcl: { fields: [ { x: 288, y: 150 }, // 名称 { x: 288, y: 225 }, // 类别 { x: 288, y: 300 }, // 代码 { x: 720, y: 300 }, // 形态 { x: 288, y: 375 }, // 主要成分 { x: 288, y: 525 }, // 有害成分 { x: 288, y: 672 }, // 注意 { x: 324, y: 804 }, // 识别码 { x: 384, y: 876 }, // 单位 { x: 432, y: 948 }, // 联系 { x: 288, y: 1020 }, // 日期 { x: 720, y: 1020 }, // 重量 { x: 225, y: 1092 }, // 备注 { x: 720, y: 525 } // 标识 ], qrCode: { x: 936, y: 876, size: 4 } }, tspl: { startX: 30, startY: 30, lineHeight: 38, fontSize: '4', qrCode: { x: 225, y: 600, size: 5 } } }, '200x200': { labelWidth: 200, labelHeight: 1600, fontSize: 48, lineHeight: 80, cpcl: { fields: [ { x: 384, y: 200 }, // 名称 { x: 384, y: 300 }, // 类别 { x: 384, y: 400 }, // 代码 { x: 960, y: 400 }, // 形态 { x: 384, y: 500 }, // 主要成分 { x: 384, y: 700 }, // 有害成分 { x: 384, y: 896 }, // 注意 { x: 432, y: 1072 }, // 识别码 { x: 512, y: 1168 }, // 单位 { x: 576, y: 1264 }, // 联系 { x: 384, y: 1360 }, // 日期 { x: 960, y: 1360 }, // 重量 { x: 300, y: 1456 }, // 备注 { x: 960, y: 700 } // 标识 ], qrCode: { x: 1248, y: 1168, size: 4 } }, tspl: { startX: 40, startY: 40, lineHeight: 50, fontSize: '4', qrCode: { x: 300, y: 800, size: 5 } } } }; class BluetoothPrinterService { constructor() { // 蓝牙状态 this.adapterOpen = false; this.connected = false; this.connecting = false; this.deviceId = ''; this.deviceName = ''; this.serviceId = ''; this.writeCharId = ''; // 打印配置 this.commandType = 'CPCL'; // 'CPCL' | 'TSPL' this.paperWidth = 100; this.paperHeight = 100; this.currentPreset = null; // 当前使用的预设名称 // 回调函数 this.connectionChangeCallbacks = []; // 连接状态监听器 this._connectionListener = null; // writeType 缓存(探测一次后记住,避免重复探测) this._writeType = null; // null | 'writeNoResponse' | 'write' // MTU 缓存(连接时通过 setBLEMTU 协商,蓝牙 5.0 理论最大 512) this._mtu = 20; } // ==================== 初始化 ==================== /** * 初始化蓝牙适配器 * @returns {Promise} */ async init() { if (this.adapterOpen) return; return new Promise((resolve, reject) => { uni.openBluetoothAdapter({ success: () => { this.adapterOpen = true; this._registerConnectionListener(); resolve(); }, fail: (err) => { console.error('蓝牙初始化失败', err); reject(err); } }); }); } /** * 注册连接状态变化监听 */ _registerConnectionListener() { if (this._connectionListener) return; this._connectionListener = (res) => { if (!res.connected && this.deviceId === res.deviceId) { // 只更新连接状态,保留配置信息以便下次自动连接 this.connected = false; this._notifyConnectionChange(false); // 注意:不再调用 _updateStoreConnection,不再清空 store 中的配置 } }; uni.onBLEConnectionStateChange(this._connectionListener); } /** * 销毁,清理资源 */ destroy() { if (this._connectionListener) { // uni.offBLEConnectionStateChange 无法精确移除单个监听 // 业务层需要在页面卸载时主动调用 disconnect } this.disconnect(); } // ==================== 连接管理 ==================== /** * 从 store 加载配置 */ _loadConfig() { try { const state = getStore().state; if (state.print) { const config = state.print.printerConfig; if (config) { this.deviceId = config.deviceId || ''; this.deviceName = config.deviceName || ''; this.serviceId = config.serviceId || ''; this.writeCharId = config.writeCharId || ''; this.paperWidth = config.paperWidth || 100; this.paperHeight = config.paperHeight || 100; this.currentPreset = config.currentPreset || null; return config; } } } catch (e) { console.warn('加载打印配置失败', e); } return null; } /** * 保存配置到 store */ _saveConfig(config) { try { getStore().dispatch('print/savePrinterConfig', config); } catch (e) { console.warn('保存打印配置失败', e); } } /** * 更新 store 连接状态 */ _updateStoreConnection(connected, device = {}) { try { if (connected) { getStore().dispatch('print/connectPrinter', { deviceId: this.deviceId, name: this.deviceName, ...device }); } else { getStore().dispatch('print/disconnectPrinter'); } } catch (e) { console.warn('更新连接状态失败', e); } } /** * 自动连接已保存的打印机 * @returns {Promise} */ async autoConnect() { if (this.connecting || this.connected) { return this.connected; } const config = this._loadConfig(); if (!config || !config.deviceId) { console.warn('无可用打印机配置'); return false; } this.connecting = true; this._notifyConnectionChange(false, 'connecting'); try { await this.init(); // 传递已保存的 serviceId 和 writeCharId await this._connect(config.deviceId, config.serviceId, config.writeCharId); this.connected = true; this.connecting = false; this._notifyConnectionChange(true); return true; } catch (err) { this.connected = false; this.connecting = false; this._notifyConnectionChange(false, 'failed'); console.error('自动连接失败', err); return false; } } /** * 连接指定设备 * @param {string} deviceId - 设备ID * @param {string} savedServiceId - 已保存的服务ID(可选) * @param {string} savedWriteCharId - 已保存的特征值ID(可选) * @returns {Promise} */ async _connect(deviceId, savedServiceId = '', savedWriteCharId = '') { return new Promise(async (resolve, reject) => { // 首先尝试使用已保存的服务配置 if (savedServiceId && savedWriteCharId) { try { console.log('尝试使用已保存的服务配置:', { savedServiceId, savedWriteCharId }); // 建立连接 await new Promise((res, rej) => { uni.createBLEConnection({ deviceId, timeout: 15000, success: res, fail: rej }); }); // MTU 协商(蓝牙 5.0 理论最大 512,实际由设备/系统决定) try { const mtuRes = await new Promise((res) => { uni.setBLEMTU({ deviceId, mtu: 512, success: res, fail: () => res({ mtu: 23 }) }); }); this._mtu = (mtuRes.mtu || 512) - 3; console.log(`[打印] MTU 协商: 请求 512, 实际=${mtuRes.mtu}, 写入块=${this._mtu}`); } catch (e) { console.warn('[打印] MTU 协商异常, 使用默认值 20', e); this._mtu = 20; } // 等待服务发现(像 configPrinter 一样) await this._delay(800); // ✅ 直接使用已保存的配置,不做任何验证 // 原因:调用 getBLEDeviceCharacteristics 后该特征值会变得不可写(10007) console.log('使用已保存配置连接成功'); this.deviceId = deviceId; this.serviceId = savedServiceId; this.writeCharId = savedWriteCharId; // 更新 store this._saveConfig({ deviceId, serviceId: savedServiceId, writeCharId: savedWriteCharId }); resolve(); return; } catch (err) { // 连接失败,回退到自动发现 console.warn('使用已保存配置连接失败,回退到自动发现:', err); await this._disconnectDevice(deviceId); } } // 自动发现服务逻辑(保底方案) uni.createBLEConnection({ deviceId, timeout: 15000, success: async () => { // 等待服务发现 await this._delay(800); // 获取服务列表 const { services } = await this._getServices(deviceId); if (!services || services.length === 0) { reject(new Error('未找到蓝牙服务')); return; } // 查找第一个有可写特征值的服务 let foundService = null; let writeChar = null; for (const service of services) { try { const { characteristics } = await this._getCharacteristics(deviceId, service.uuid); const writableChar = characteristics.find(c => c.properties.write || c.properties.writeNoResponse ); if (writableChar) { foundService = service; writeChar = writableChar; break; } } catch (e) { // 跳过无效服务 continue; } } if (!writeChar) { reject(new Error('未找到可写入的特征值')); return; } this.deviceId = deviceId; this.serviceId = foundService.uuid; this.writeCharId = writeChar.uuid; // 保存配置 this._saveConfig({ deviceId, serviceId: foundService.uuid, writeCharId: writeChar.uuid }); resolve(); }, fail: reject }); }); } /** * 断开设备连接(内部方法) */ async _disconnectDevice(deviceId) { try { await new Promise((resolve, reject) => { uni.closeBLEConnection({ deviceId, success: resolve, fail: reject }); }); } catch (e) { console.warn('断开设备失败', e); } } /** * 断开连接 * 注意:只做物理断开,保留配置信息以便下次自动连接 */ async disconnect() { if (!this.deviceId) return; try { await new Promise((resolve, reject) => { uni.closeBLEConnection({ deviceId: this.deviceId, success: resolve, fail: reject }); }); } catch (e) { console.warn('断开连接失败', e); } // 只更新连接状态,保留 deviceId、deviceName、serviceId、writeCharId 等配置 this.connected = false; this._notifyConnectionChange(false); // 注意:不再清空配置,不再调用 _updateStoreConnection } /** * 检查是否已连接 */ isConnected() { return this.connected; } // ==================== 打印操作 ==================== /** * 打印文本内容 * @param {string} content - 打印内容(支持 CPCL/TSPL 指令) * @param {Object} options - 发送选项 * @param {boolean} options.useChunk - 是否使用分片发送(默认自动判断) * @param {number} options.chunkSize - 分片大小(字节),默认 20 * @param {number} options.chunkDelay - 分片间隔(毫秒),默认 50 * @returns {Promise} */ async printText(content, options = {}) { if (!this.connected) { throw new Error('打印机未连接'); } const buffer = this._stringToBuffer(content); const dataSize = buffer.byteLength; const lines = content.split('\n'); console.log(`[打印] 共 ${lines.length} 行, ${dataSize} bytes`); // 行数少时一次发送(更快),行数多时按行发送(防止指令被拆包) const LINE_THRESHOLD = 22; if (lines.length <= LINE_THRESHOLD) { console.log('[打印] 行数较少,一次发送'); await this._writeData(buffer); } else { console.log('[打印] 行数较多,按行分片发送'); await this._writeDataByLines(content); } return true; } /** * 按行分片发送(推荐用于 CPCL/TSPL 指令) * 确保每一行指令完整,不被 BLE 包拆分 * @param {string} content - 打印指令(多行字符串) */ async _writeDataByLines(content) { const lines = content.split('\n'); console.log(`[打印] 按行发送,共 ${lines.length} 行`); for (let i = 0; i < lines.length; i++) { const line = lines[i] + '\n'; const buffer = this._stringToBuffer(line); try { await this._writeData(buffer); // 每行之间延迟 80ms,给打印机处理时间 if (i < lines.length - 1) { await this._delay(80); } } catch (err) { const isLastLine = i === lines.length - 1; if (isLastLine) { // 尾行容错:与 _writeData 保持一致,最后一行写入失败不抛异常 console.warn(`[打印] 第 ${i + 1} 行(最后一行)发送失败,视为尾包处理:`, err); return; } console.error(`[打印] 第 ${i + 1} 行发送失败:`, err); throw err; } } console.log('[打印] 按行发送完成'); } /** * 打印废物标签 * @param {Object} data - 废物数据 * @returns {Promise} */ async printWasteLabel(data) { const command = this.generateWasteLabel(data); return this.printText(command); } /** * 打印二维码 * @param {string} content - 二维码内容 * @returns {Promise} */ async printQRCode(content) { const command = this.generateQRCodeTemplate(content); return this.printText(command); } /** * 打印条码 * @param {string} content - 条码内容 * @param {string} type - 条码类型,默认 128 * @returns {Promise} */ async printBarcode(content, type = '128') { const command = this.generateBarcodeTemplate(content, type); return this.printText(command); } // ==================== 指令生成 ==================== /** * 获取纸张尺寸命令 */ getSizeCommand() { if (this.commandType === 'CPCL') { return `SIZE ${this.paperWidth}mm ${this.paperHeight}mm`; } else { return `SIZE ${this.paperWidth} mm ${this.paperHeight} mm`; } } /** * 生成废物标签指令 * @param {Object} data - 废物数据 */ generateWasteLabel(data) { const sizeCmd = this.getSizeCommand(); const gapCmd = this.commandType === 'CPCL' ? 'GAP 2mm' : 'GAP 2 mm'; if (this.commandType === 'CPCL') { return this._generateCPCLWasteLabel(data, sizeCmd, gapCmd); } else { return this._generateTSPLWasteLabel(data, sizeCmd, gapCmd); } } /** * 生成 CPCL 废物标签(支持预设布局自适应) */ _generateCPCLWasteLabel(data, sizeCmd, gapCmd) { const fieldOrder = [ 'mingcheng', 'leibie', 'daima', 'xingtai', 'zhuyaochengfen', 'youhaichengfen', 'zhuyi', 'shibiema', 'danwei', 'lianxi', 'riqi', 'zhongliang', 'beizhu', 'biaoshi' ]; const fieldLabels = { mingcheng: '名称', leibie: '类别', daima: '代码', xingtai: '形态', zhuyaochengfen: '主要成分', youhaichengfen: '有害成分', zhuyi: '注意', shibiema: '识别码', danwei: '单位', lianxi: '联系', riqi: '日期', zhongliang: '重量', beizhu: '备注', biaoshi: '●' }; // 优先使用预设布局,否则使用默认配置 const layout = this._getCurrentLayout(); const conf = (layout && layout.fields) ? layout.fields : [ { x: 192, y: 100 }, { x: 192, y: 150 }, { x: 192, y: 200 }, { x: 480, y: 200 }, { x: 192, y: 250 }, { x: 192, y: 350 }, { x: 192, y: 448 }, { x: 216, y: 536 }, { x: 256, y: 584 }, { x: 288, y: 632 }, { x: 192, y: 680 }, { x: 480, y: 680 }, { x: 150, y: 728 }, { x: 480, y: 448 } ]; const labelHeight = (layout && layout.labelHeight) || 800; let commands = []; commands.push(`! 0 200 200 ${labelHeight} 1`); commands.push(sizeCmd); fieldOrder.forEach((field, index) => { if (data[field]) { const { x, y } = conf[index]; commands.push(`T 1 0 ${x} ${y} ${data[field]}`); } }); if (data.qrcode) { const qrc = (layout && layout.qrCode) || { x: 624, y: 584, size: 4 }; commands.push(`B QR ${qrc.x} ${qrc.y} M 2 U ${qrc.size}`); commands.push(`L0,${data.qrcode}\r\n`); commands.push('ENDQR'); } commands.push('FORM'); commands.push('PRINT'); return commands.join('\n'); } /** * 生成 TSPL 废物标签(支持预设布局自适应) */ _generateTSPLWasteLabel(data, sizeCmd, gapCmd) { const fieldOrder = [ 'mingcheng', 'leibie', 'daima', 'xingtai', 'zhuyaochengfen', 'youhaichengfen', 'zhuyi', 'shibiema', 'danwei', 'lianxi', 'riqi', 'zhongliang', 'beizhu' ]; const fieldLabels = { mingcheng: '名称', leibie: '类别', daima: '代码', xingtai: '形态', zhuyaochengfen: '主要成分', youhaichengfen: '有害成分', zhuyi: '注意', shibiema: '识别码', danwei: '单位', lianxi: '联系', riqi: '日期', zhongliang: '重量', beizhu: '备注' }; // 优先使用预设布局,否则使用默认配置 const layout = this._getCurrentLayout(); const startX = (layout && layout.startX) || 20; const lineHeight = (layout && layout.lineHeight) || 25; const fontSize = (layout && layout.fontSize) || '4'; let commands = []; commands.push(sizeCmd); commands.push(gapCmd); commands.push('CLS'); fieldOrder.forEach((field, index) => { if (data[field]) { const y = startX + (index * lineHeight); const content = `${data[field]}`; commands.push(`TEXT ${startX} ${y} "${fontSize}" 0 1 1 "${content}"`); } }); if (data.qrcode) { const qrc = (layout && layout.qrCode) || { x: 150, y: 400, size: 5 }; commands.push(`QRCODE ${qrc.x} ${qrc.y} L ${qrc.size} 0 A 0 "${data.qrcode}"`); } commands.push('PRINT'); return commands.join('\n'); } /** * 生成二维码模板 * @param {string} content - 二维码内容 */ generateQRCodeTemplate(content) { const sizeCmd = this.getSizeCommand(); const gapCmd = this.commandType === 'CPCL' ? 'GAP 2mm' : 'GAP 2 mm'; if (this.commandType === 'CPCL') { return `! 0 200 200 240 1 ${sizeCmd} ${gapCmd} CENTER QR 300 10 M 2 A 0 MA,${content} ENDQR PRINT`; } else { return `${sizeCmd} ${gapCmd} CLS QRCODE 50 50 L 5 0 A 0 "${content}" PRINT`; } } /** * 生成条码模板 * @param {string} content - 条码内容 * @param {string} type - 条码类型 */ generateBarcodeTemplate(content, type = '128') { const sizeCmd = this.getSizeCommand(); const gapCmd = this.commandType === 'CPCL' ? 'GAP 2mm' : 'GAP 2 mm'; if (this.commandType === 'CPCL') { return `! 0 200 200 240 1 ${sizeCmd} ${gapCmd} CENTER BARCODE 128 1 1 60 1 0 10 40 ${content} PRINT`; } else { return `${sizeCmd} ${gapCmd} CLS BARCODE 50 50 "${type}" 50 1 0 2 2 "${content}" PRINT`; } } // ==================== 配置管理 ==================== /** * 设置指令类型 * @param {'CPCL' | 'TSPL'} type */ setCommandType(type) { this.commandType = type; this._saveConfig({ commandType: type }); } /** * 设置纸张尺寸 * @param {number} width - 宽度(mm) * @param {number} height - 高度(mm) */ setPaperSize(width, height) { this.paperWidth = width; this.paperHeight = height; // 检测是否匹配预设尺寸 const presetKey = `${width}x${height}`; if (PAPER_PRESETS[presetKey]) { this.currentPreset = presetKey; } else { this.currentPreset = null; } this._saveConfig({ paperWidth: width, paperHeight: height, currentPreset: this.currentPreset }); } /** * 获取所有可用的预设纸张尺寸 * @returns {Array<{key: string, label: string, width: number, height: number}>} */ static getPresetPaperSizes() { return Object.keys(PAPER_PRESETS).map(key => { const preset = PAPER_PRESETS[key]; return { key, label: `${preset.labelWidth} × ${preset.labelWidth} mm`, width: preset.labelWidth, height: preset.labelWidth }; }); } /** * 使用预设纸张尺寸 * @param {string} presetKey - 预设键名,如 '100x100', '150x150', '200x200' */ usePresetPaperSize(presetKey) { const preset = PAPER_PRESETS[presetKey]; if (!preset) { console.warn(`预设纸张尺寸 "${presetKey}" 不存在`); return; } this.paperWidth = preset.labelWidth; this.paperHeight = preset.labelWidth; this.currentPreset = presetKey; this._saveConfig({ paperWidth: preset.labelWidth, paperHeight: preset.labelWidth, currentPreset: presetKey }); } /** * 获取当前预设的布局配置 * @returns {object|null} */ _getCurrentLayout() { if (!this.currentPreset || !PAPER_PRESETS[this.currentPreset]) { return null; } const preset = PAPER_PRESETS[this.currentPreset]; const type = this.commandType === 'CPCL' ? 'cpcl' : 'tspl'; return { ...preset[type], labelHeight: preset.labelHeight, fontSize: preset.fontSize, lineHeight: preset.lineHeight }; } /** * 获取当前配置 */ getConfig() { return { connected: this.connected, deviceId: this.deviceId, deviceName: this.deviceName, serviceId: this.serviceId, writeCharId: this.writeCharId, commandType: this.commandType, paperWidth: this.paperWidth, paperHeight: this.paperHeight, currentPreset: this.currentPreset }; } // ==================== 回调管理 ==================== /** * 注册连接状态变化回调 * @param {Function} callback - (connected: boolean, status?: string) => void */ onConnectionChange(callback) { this.connectionChangeCallbacks.push(callback); } /** * 移除连接状态变化回调 */ offConnectionChange(callback) { const index = this.connectionChangeCallbacks.indexOf(callback); if (index > -1) { this.connectionChangeCallbacks.splice(index, 1); } } /** * 通知连接状态变化 */ _notifyConnectionChange(connected, status) { this.connectionChangeCallbacks.forEach(cb => { try { cb(connected, status); } catch (e) { console.warn('连接状态回调执行失败', e); } }); } // ==================== 工具方法 ==================== /** * 字符串转 ArrayBuffer (GBK编码) */ _stringToBuffer(str) { try { const uint8Array = iconv.encode(str, 'gbk'); return uint8Array.buffer; } catch (e) { console.warn('GBK编码失败,使用回退方案', e); const buffer = new ArrayBuffer(str.length); const dataView = new DataView(buffer); for (let i = 0; i < str.length; i++) { dataView.setUint8(i, str.charCodeAt(i) & 0xFF); } return buffer; } } /** * 写入数据到蓝牙设备(自动适配 MTU) * 策略: * 1. 数据 > MTU 时自动走分片发送(_writeDataInChunks) * 2. 数据 ≤ MTU 时一次发送,writeType 自动重试 * 3. 失败时切换 writeType 重试(null → writeNoResponse → write) * 4. 失败时增加延迟(BLE 协议栈繁忙)后再次重试 */ async _writeData(buffer, attempt = 0) { const dataLen = buffer.byteLength; const mtu = this._mtu || 20; // 数据超过 MTU?自动走分片 if (dataLen > mtu) { console.log(`[打印] 数据 ${dataLen}B > MTU ${mtu}B, 自动分片发送`); return this._writeDataInChunks(buffer, mtu); } const writeTypes = [null, 'writeNoResponse', 'write']; // 依次尝试 const maxAttempts = 3; // 总共最多重试 3 次 if (attempt >= maxAttempts) { // 尾包/单包容错:数据已基本送达(最后一次 writeBLECharacteristicValue 触发 errCode=10007 // 通常是打印机刚处理完上一批数据时协议栈返回 property not support,并非真正写入失败)。 // 此处不再抛异常,静默视为成功以保证整个打印流程不中断。 console.warn(`[打印] 写入重试 ${maxAttempts} 次仍失败,视为尾包/打印机忙导致,数据可能已送达`); return { success: true, silent: true, reason: 'max attempts reached, treat as success (tail-packet tolerance)' }; } // 选择本次尝试的 writeType const writeType = writeTypes[attempt] || null; const options = { deviceId: this.deviceId, serviceId: this.serviceId, characteristicId: this.writeCharId, value: buffer }; // 只有当有指定值时才设置 writeType if (writeType) { options.writeType = writeType; } console.log(`[打印] 写入尝试 ${attempt + 1}/${maxAttempts}, writeType=${writeType || 'auto'}`); return new Promise((resolve, reject) => { uni.writeBLECharacteristicValue({ ...options, success: (res) => { // 成功后缓存 writeType this._writeType = writeType; console.log(`[打印] 写入成功, writeType=${writeType || 'auto'}`); resolve(res); }, fail: (err) => { console.warn(`[打印] 写入失败 attempt=${attempt + 1}, errCode=${err.errCode}, msg=${err.errMsg}`); // 延迟后重试(递增延迟,给 BLE 协议栈恢复时间) const delay = 100 + attempt * 150; // 100ms, 250ms, 400ms setTimeout(() => { this._writeData(buffer, attempt + 1).then(resolve).catch(reject); }, delay); } }); }); } /** * 发送单个数据包(分片用)—— 带 writeType 容错 + 多重试 * @param {ArrayBuffer} chunkBuffer - 数据包 * @param {boolean} retryWithWrite - 是否用 'write' 模式重试 * @param {number} attempt - 当前重试次数(内部使用) */ _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) => { // writeNoResponse 不支持时降级为 write 模式重试 if (!retryWithWrite && err.errCode === 10007) { console.warn('[分片] writeNoResponse 不支持,降级为 write 模式'); try { await this._delay(50); await this._writeChunk(chunkBuffer, true, 0); resolve(); } catch (downgradeErr) { reject(downgradeErr); } return; } // 10007 设备忙:等 100ms 重试,最多 3 次 if (err.errCode === 10007 && attempt < 3) { const wait = 100 * (attempt + 1); console.warn(`[分片] 设备忙(10007),${wait}ms 后第 ${attempt + 1}/3 次重试...`); try { await this._delay(wait); await this._writeChunk(chunkBuffer, retryWithWrite, attempt + 1); resolve(); } catch (retryErr) { reject(retryErr); } return; } reject(err); } }); }); } /** * 发送单个数据包 —— 强制使用 'write' 模式(带响应) * 用于尾包等关键数据:write 模式会等待 ACK,确保数据真的到达打印机 * @param {ArrayBuffer} chunkBuffer - 数据包 */ _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 }); }); } /** * 获取设备 MTU(最大传输单元) * @returns {Promise} */ async _getMTU() { try { const result = await new Promise((resolve, reject) => { uni.getBLEMTU({ deviceId: this.deviceId, success: resolve, fail: reject }); }); // MTU 减去 3 字节的 ATT 头信息为有效载荷 return (result.mtu || 23) - 3; } catch (e) { console.warn('获取 MTU 失败,使用默认值 20', e); return 20; } } /** * 根据数据大小计算合适的分片延迟 * @param {number} dataSize - 数据大小(字节) * @returns {number} 延迟时间(毫秒) */ _getChunkDelay(dataSize) { if (dataSize > 5120) return 200; // 超过 5KB if (dataSize > 1024) return 100; // 超过 1KB return 50; } /** * 分片发送数据(适合大数据) * @param {ArrayBuffer} buffer - 要发送的数据 * @param {number} mtu - 每包大小(字节),默认走协商后的 _mtu,兜底 20 * @param {number|null} delay - 包之间的延迟(毫秒),传 null 时按数据量自适应 */ async _writeDataInChunks(buffer, mtu = null, delay = null) { const totalLength = buffer.byteLength; // 参数兜底:传参 > 缓存的 _mtu > 默认 20 mtu = mtu || this._mtu || 20; // delay 自适应:传了具体值就用,否则按数据量计算 delay = delay ?? this._getChunkDelay(totalLength); const dataView = new Uint8Array(buffer); console.log(`开始分片发送: 总大小=${totalLength}字节, MTU=${mtu}字节/包, 延迟=${delay}ms`); 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) % 10 === 0) { console.log(`发送进度: ${end}/${totalLength}`); } if (delay > 0 && end < totalLength) { await this._delay(delay); } } catch (err) { // 尾包容错:最后一包发送失败时,多重试几次(递增延迟 + 强制 write 模式确认) // 原因:数据已基本送达,打印机在处理打印任务时 BLE 通道会返回 10007 "property not support"(实际为设备忙) // 这种情况多等一会打印机就能接收,但若直接放弃会导致 QR 码等关键数据被截断 if (isLastChunk) { const retryDelays = [300, 600, 1000, 1500, 2000]; // 递增延迟 let delivered = false; for (let attempt = 0; attempt < retryDelays.length; attempt++) { const wait = retryDelays[attempt]; console.warn(`尾包发送失败(offset=${offset}, size=${end - offset}B),第 ${attempt + 1}/${retryDelays.length} 次重试,等待 ${wait}ms...`, err); await this._delay(wait); try { // 强制使用 write 模式(带响应)发送尾包,确保打印机真的收到了 await this._writeChunkWithResponse(chunkBuffer); console.log(`尾包重试成功(offset=${offset}, attempt=${attempt + 1})`); delivered = true; break; } catch (retryErr) { console.warn(`尾包第 ${attempt + 1} 次重试仍失败:`, retryErr); } } if (delivered) { continue; } // 5 次重试全部失败:仍按原策略静默成功(避免上层误报),但通过 console.error 醒目提示数据可能不完整 console.error(`【打印数据可能不完整】尾包 5 次重试均失败(offset=${offset}, size=${end - offset}B),末尾 ${end - offset} 字节可能丢失,请检查打印结果!`); return; } console.error(`分片发送失败 at offset ${offset}:`, err); throw err; } } console.log('分片发送完成'); } /** * 计算打印指令的数据长度(字节) * @param {string} command - 打印指令字符串 * @returns {number} 字节长度 */ calculateCommandSize(command) { try { const uint8Array = iconv.encode(command, 'gbk'); return uint8Array.length; } catch (e) { return command.length; } } /** * 延迟 */ _delay(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } /** * 获取设备服务列表 */ _getServices(deviceId) { return new Promise((resolve, reject) => { uni.getBLEDeviceServices({ deviceId, success: resolve, fail: reject }); }); } /** * 获取服务特征值列表 */ _getCharacteristics(deviceId, serviceId) { return new Promise((resolve, reject) => { uni.getBLEDeviceCharacteristics({ deviceId, serviceId, success: resolve, fail: reject }); }); } } // 导出单例 export default new BluetoothPrinterService();