|
|
@@ -0,0 +1,442 @@
|
|
|
+/**
|
|
|
+ * 编码横向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;
|