| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442 |
- /**
- * 编码横向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;
|