/** * 编码横向1位点阵(1为黑,最高位为左像素),不能直接传PNG或Base64。 * 标准模式、正常倍率、空行缓冲下使用GS v 0;保持所有像素和空白高度。 */ export function encodeMonoRaster(data, width, height, options = {}) { if (!(data instanceof Uint8Array) || !Number.isInteger(width) || width < 8 || width > 384 || width % 8 || !Number.isInteger(height) || height < 1 || height > 40000 || data.length !== width / 8 * height) { throw new Error('点阵必须是宽度不超过384且为8的倍数、长度匹配的1位黑白数据'); } const stride = width / 8, blocks = []; const append = (from, to, blank) => { for (let y = from; y < to; y += 2048) { const rows = Math.min(2048, to - y), columns = blank ? 1 : stride; const block = new Uint8Array(8 + columns * rows); block.set([29, 118, 48, 0, columns, 0, rows & 255, rows >> 8]); if (!blank) block.set(data.subarray(y * stride, (y + rows) * stride), 8); blocks.push(block); } }; let contentStart = 0; if (options.compactBlankRows !== false) { let y = 0; while (y < height) { const start = y; while (y < height && data.subarray(y * stride, (y + 1) * stride).every(value => value === 0)) y++; if (y - start >= 8) { append(contentStart, start, false); // 全白行只传1字节宽,仍按原行数打印,不依赖走纸指令的单位。 append(start, y, true); contentStart = y; } if (start === y) y++; } } append(contentStart, height, false); let length = blocks.reduce((sum, block) => sum + block.length, 0); // 块头使数据变大时退回原始编码,适用于极窄图或密集短空白。 if (length > data.length + Math.ceil(height / 2048) * 8) { blocks.length = 0; append(0, height, false); length = blocks.reduce((sum, block) => sum + block.length, 0); } const result = new Uint8Array(length); let offset = 0; for (const block of blocks) { result.set(block, offset); offset += block.length; } return result.buffer; } export function choosePrinterCharacteristic(candidates) { const writable = candidates.filter(item => { const service = String(item.serviceId).toLowerCase(); const characteristic = String(item.characteristicId).toLowerCase(); // GAP/GATT and device information characteristics are not print data channels. if (/^000018(?:0[0-9a-f]|[12][0-9a-f]|3[0-9a-f])-0000-1000-8000-00805f9b34fb$/.test(service) || /^00002a00-0000-1000-8000-00805f9b34fb$/.test(characteristic)) return false; return item.write || item.writeNoResponse; }); writable.sort((a, b) => { const priority = item => /^(0000ff00|000018f0)-/i.test(item.serviceId) ? 0 : 1; return priority(a) - priority(b) || Number(!!b.write) - Number(!!a.write); }); return writable[0] || null; } /** * 蓝牙打印机管理器 * 处理蓝牙连接和数据发送 */ class BluetoothPrinter { constructor(options = {}) { this.deviceId = ''; this.serviceId = ''; this.characteristicId = ''; this.writeType = 'write'; // 写入类型:'write' 或 'writeNoResponse' this.writeDelayMs = 0; this.transportProfile = null; this.sending = false; this.streamInterrupted = false; this.writeTimeoutMs = Number.isFinite(options.writeTimeoutMs) && options.writeTimeoutMs > 0 ? options.writeTimeoutMs : 10000; this.isConnected = false; this.silent = options.silent || false; // 是否静默模式(不显示提示) } /** * 初始化蓝牙适配器 * @returns {Promise} */ async initBluetoothAdapter() { return new Promise((resolve, reject) => { uni.openBluetoothAdapter({ success: (res) => { console.log('蓝牙适配器打开成功'); resolve(res); }, fail: (err) => { console.error('蓝牙适配器打开失败:', err); if (err.errCode === 10001 && !this.silent) { // 只有在非静默模式下才显示提示 uni.showModal({ title: '提示', content: '检测到蓝牙未打开,请打开蓝牙后重试', showCancel: true, confirmText: '知道了' }); } reject(err); } }); }); } /** * 搜索蓝牙设备 * @param {Function} onDeviceFound - 发现设备时的回调函数 * @returns {Promise} */ async searchDevices(onDeviceFound) { return new Promise((resolve, reject) => { uni.getBluetoothAdapterState({ success: (res) => { if (!res.available) { reject(new Error('本机蓝牙不可用')); return; } if (res.discovering) { uni.stopBluetoothDevicesDiscovery({ success: () => { this.startDiscovery(onDeviceFound, resolve, reject); } }); } else { this.startDiscovery(onDeviceFound, resolve, reject); } }, fail: reject }); }); } /** * 开始搜索设备 */ startDiscovery(onDeviceFound, resolve, reject) { // 监听寻找到新设备的事件 uni.onBluetoothDeviceFound((devices) => { if (onDeviceFound) { onDeviceFound(devices); } }); // 获取平台信息 const systemInfo = uni.getSystemInfoSync(); const isIOS = systemInfo.platform === 'ios'; // iOS 端可能需要指定 services 参数,但先尝试不指定(通用蓝牙搜索) // 如果打印机支持标准 BLE 服务,可以尝试添加 services 参数 // 常见的 ESC/POS 打印机服务 UUID: '0000ff00-0000-1000-8000-00805f9b34fb' 或 '000018f0-0000-1000-8000-00805f9b34fb' const discoveryOptions = { success: (res) => { console.log('开始搜索蓝牙设备', isIOS ? '(iOS)' : '(Android)'); resolve(res); }, fail: (err) => { console.error('搜索蓝牙设备失败:', err); // iOS 端如果失败,可能是权限问题或需要 services 参数 if (isIOS) { console.warn('iOS 端搜索失败,可能需要检查蓝牙权限或指定服务 UUID'); } reject(err); } }; // iOS 端可以尝试添加 allowDuplicatesKey 参数,允许重复发现设备 if (isIOS) { discoveryOptions.allowDuplicatesKey = false; // iOS 端建议设为 false } uni.startBluetoothDevicesDiscovery(discoveryOptions); } /** * 停止搜索设备 */ stopDiscovery() { return new Promise((resolve, reject) => { uni.stopBluetoothDevicesDiscovery({ success: resolve, fail: reject }); }); } /** * 连接蓝牙设备 * @param {String} deviceId - 设备ID * @returns {Promise} */ async connect(deviceId) { return new Promise((resolve, reject) => { // 如果已经连接到同一个设备,直接返回成功 if (this.isConnected && this.deviceId === deviceId && this.serviceId && this.characteristicId) { console.log('蓝牙已连接,无需重复连接'); resolve(); return; } if (this.sending) { reject(new Error('打印发送进行中,不能切换连接')); return; } this.transportProfile = null; uni.createBLEConnection({ deviceId: deviceId, success: (res) => { console.log('蓝牙连接成功'); this.deviceId = deviceId; this.isConnected = true; // 连接成功后获取服务,增加延迟确保设备完全准备好 // iOS 端可能需要更长的延迟时间 const systemInfo = uni.getSystemInfoSync(); const isIOS = systemInfo.platform === 'ios'; const delay = isIOS ? 500 : 300; // iOS 延迟 500ms,Android 延迟 300ms setTimeout(() => { this.getBLEServices(deviceId).then(resolve).catch(reject); }, delay); }, fail: (err) => { // 如果错误是"already connect",当作成功处理 if (err.errMsg && err.errMsg.includes('already connect')) { console.log('蓝牙已经连接'); this.deviceId = deviceId; this.isConnected = true; // 已经连接的话,直接获取服务 setTimeout(() => { this.getBLEServices(deviceId).then(resolve).catch(reject); }, 100); } else { console.error('蓝牙连接失败:', err); this.isConnected = false; reject(err); } } }); }); } /** * 获取蓝牙设备服务 * @param {String} deviceId - 设备ID * @returns {Promise} */ async getBLEServices(deviceId) { return new Promise((resolve, reject) => { uni.getBLEDeviceServices({ deviceId: deviceId, success: (res) => { if (!res.services || res.services.length === 0) { reject(new Error('该蓝牙设备不可用')); return; } const servicesPromise = []; const candidates = []; for (let s = 0; s < res.services.length; s++) { const serviceId = res.services[s].uuid; const prom = new Promise((resolveService) => { uni.getBLEDeviceCharacteristics({ deviceId: deviceId, serviceId: serviceId, success: (charRes) => { if (charRes.characteristics && charRes.characteristics.length > 0) { for (let c = 0; c < charRes.characteristics.length; c++) { const char = charRes.characteristics[c]; candidates.push({ serviceId, characteristicId: char.uuid, write: char.properties.write === true, writeNoResponse: char.properties.writeNoResponse === true }); } } // 无论是否找到特征值,都 resolve,继续尝试下一个服务 resolveService(); }, fail: (err) => { // 某些服务可能没有特征值(如系统服务),这是正常的,不视为错误 // 继续尝试下一个服务 console.warn(`服务 ${serviceId} 获取特征值失败:`, err.errMsg || err); resolveService(); } }); }); servicesPromise.push(prom); } // 使用 Promise.allSettled 确保所有服务都尝试完成,即使某些失败也不影响 Promise.allSettled(servicesPromise).then(() => { const selected = choosePrinterCharacteristic(candidates); if (selected) { this.transportProfile = null; this.serviceId = selected.serviceId; this.characteristicId = selected.characteristicId; this.writeType = selected.write ? 'write' : 'writeNoResponse'; console.log('找到可写特征值:', this.serviceId, this.characteristicId, '写入类型:', this.writeType, '支持 write:', selected.write, '支持 writeNoResponse:', selected.writeNoResponse); resolve(); } else { reject(new Error('未找到蓝牙打印数据通道,请确认设备支持 BLE ESC/POS 打印')); } }); }, fail: reject }); }); } /** * 断开蓝牙连接 */ disconnect() { this.transportProfile = null; if (this.deviceId && this.isConnected) { uni.closeBLEConnection({ deviceId: this.deviceId }); this.deviceId = ''; this.serviceId = ''; this.characteristicId = ''; this.writeType = 'write'; // 重置写入类型 this.writeDelayMs = 0; this.isConnected = false; } } /** * 发送打印数据 * @param {ArrayBuffer} buffer - 打印数据缓冲区 * @param {Function} onProgress - 进度回调函数 (current, total) * @returns {Promise} */ setTransportProfile({ deviceId, maxWriteBytes = 20, writeDelayMs } = {}) { // 只能使用当前连接实际确认的有效负载上限,不能直接使用请求MTU。 if (this.sending || !this.isConnected || deviceId !== this.deviceId || !Number.isInteger(maxWriteBytes) || maxWriteBytes < 1 || maxWriteBytes > 509 || (writeDelayMs !== undefined && (!Number.isFinite(writeDelayMs) || writeDelayMs < 0 || writeDelayMs > 1000))) { throw new Error('打印连接传输参数无效'); } this.transportProfile = { deviceId, maxWriteBytes, writeDelayMs }; } /** 中断后需要操作员清空/重启打印机;仅重连不代表设备的点阵缓冲已清空。 */ confirmPrinterBufferCleared() { if (this.sending) throw new Error('打印发送进行中'); this.streamInterrupted = false; } async sendPrintData(buffer, onProgress) { if (this.sending) throw new Error('打印发送进行中,请顺序发送'); if (this.streamInterrupted) throw new Error('打印流已中断,请清空打印机缓冲后确认恢复'); if (!this.isConnected || !this.deviceId || !this.serviceId || !this.characteristicId) throw new Error('蓝牙未连接'); if (!(buffer instanceof ArrayBuffer)) throw new Error('打印数据必须是ArrayBuffer'); const profile = this.transportProfile?.deviceId === this.deviceId ? this.transportProfile : null; const maxChunk = profile?.maxWriteBytes || 20; const total = buffer.byteLength; const isIOS = /^(ios|iphone|ipad)$/.test(uni.getSystemInfoSync().platform); const delayMs = profile?.writeDelayMs ?? Math.max(isIOS ? 20 : 10, this.writeDelayMs); let lastProgressAt = 0; this.sending = true; try { for (let offset = 0; offset < total; offset += maxChunk) { const end = Math.min(offset + maxChunk, total); await this.writeBLECharacteristicValue(buffer.slice(offset, end)); // 未实测设备保留原安全间隔,包括末包,避免下一分段立即写入。 if (delayMs) await new Promise(resolve => setTimeout(resolve, delayMs)); if (onProgress && (end === total || Date.now() - lastProgressAt >= 100)) { lastProgressAt = Date.now(); // UI异常不能触发重发或截断打印数据。 try { onProgress(end, total); } catch (error) { console.warn('打印进度回调失败', error); } } } } catch (error) { // 失败回调不能证明设备未收到数据,禁止自动重放导致像素/指令错位。 this.streamInterrupted = true; throw error; } finally { this.sending = false; } } /** 串行写入并设置超时;迟到回调不再改变任务结果。 */ writeBLECharacteristicValue(buffer) { return new Promise((resolve, reject) => { if (!this.isConnected || !this.deviceId || !this.serviceId || !this.characteristicId) { reject(new Error('蓝牙未连接,无法写入数据')); return; } let settled = false; const finish = (error, result) => { if (settled) return; settled = true; clearTimeout(timer); if (error) reject(error); else resolve(result); }; const timer = setTimeout(() => finish(new Error('蓝牙写入超时,请检查打印机')), this.writeTimeoutMs); try { uni.writeBLECharacteristicValue({ deviceId: this.deviceId, serviceId: this.serviceId, characteristicId: this.characteristicId, value: buffer, writeType: this.writeType, success: res => { if (!res?.errMsg || res.errMsg.includes('ok')) finish(null, res); else finish(new Error(res.errMsg)); }, fail: error => finish(error) }); } catch (error) { finish(error); } }); } /** * 从本地存储加载设备ID */ loadDeviceFromStorage() { const deviceId = uni.getStorageSync('iasei-bluetooth-id'); if (deviceId) { this.deviceId = deviceId; } return deviceId; } /** * 保存设备ID到本地存储 * @param {String} deviceId - 设备ID */ saveDeviceToStorage(deviceId) { uni.setStorageSync('iasei-bluetooth-id', deviceId); this.deviceId = deviceId; } } export default BluetoothPrinter;