BluetoothPrinter.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442
  1. /**
  2. * 编码横向1位点阵(1为黑,最高位为左像素),不能直接传PNG或Base64。
  3. * 标准模式、正常倍率、空行缓冲下使用GS v 0;保持所有像素和空白高度。
  4. */
  5. export function encodeMonoRaster(data, width, height, options = {}) {
  6. if (!(data instanceof Uint8Array) || !Number.isInteger(width) || width < 8 || width > 384 ||
  7. width % 8 || !Number.isInteger(height) || height < 1 || height > 40000 || data.length !== width / 8 * height) {
  8. throw new Error('点阵必须是宽度不超过384且为8的倍数、长度匹配的1位黑白数据');
  9. }
  10. const stride = width / 8, blocks = [];
  11. const append = (from, to, blank) => {
  12. for (let y = from; y < to; y += 2048) {
  13. const rows = Math.min(2048, to - y), columns = blank ? 1 : stride;
  14. const block = new Uint8Array(8 + columns * rows);
  15. block.set([29, 118, 48, 0, columns, 0, rows & 255, rows >> 8]);
  16. if (!blank) block.set(data.subarray(y * stride, (y + rows) * stride), 8);
  17. blocks.push(block);
  18. }
  19. };
  20. let contentStart = 0;
  21. if (options.compactBlankRows !== false) {
  22. let y = 0;
  23. while (y < height) {
  24. const start = y;
  25. while (y < height && data.subarray(y * stride, (y + 1) * stride).every(value => value === 0)) y++;
  26. if (y - start >= 8) {
  27. append(contentStart, start, false);
  28. // 全白行只传1字节宽,仍按原行数打印,不依赖走纸指令的单位。
  29. append(start, y, true);
  30. contentStart = y;
  31. }
  32. if (start === y) y++;
  33. }
  34. }
  35. append(contentStart, height, false);
  36. let length = blocks.reduce((sum, block) => sum + block.length, 0);
  37. // 块头使数据变大时退回原始编码,适用于极窄图或密集短空白。
  38. if (length > data.length + Math.ceil(height / 2048) * 8) {
  39. blocks.length = 0;
  40. append(0, height, false);
  41. length = blocks.reduce((sum, block) => sum + block.length, 0);
  42. }
  43. const result = new Uint8Array(length);
  44. let offset = 0;
  45. for (const block of blocks) { result.set(block, offset); offset += block.length; }
  46. return result.buffer;
  47. }
  48. export function choosePrinterCharacteristic(candidates) {
  49. const writable = candidates.filter(item => {
  50. const service = String(item.serviceId).toLowerCase();
  51. const characteristic = String(item.characteristicId).toLowerCase();
  52. // GAP/GATT and device information characteristics are not print data channels.
  53. if (/^000018(?:0[0-9a-f]|[12][0-9a-f]|3[0-9a-f])-0000-1000-8000-00805f9b34fb$/.test(service) ||
  54. /^00002a00-0000-1000-8000-00805f9b34fb$/.test(characteristic)) return false;
  55. return item.write || item.writeNoResponse;
  56. });
  57. writable.sort((a, b) => {
  58. const priority = item => /^(0000ff00|000018f0)-/i.test(item.serviceId) ? 0 : 1;
  59. return priority(a) - priority(b) || Number(!!b.write) - Number(!!a.write);
  60. });
  61. return writable[0] || null;
  62. }
  63. /**
  64. * 蓝牙打印机管理器
  65. * 处理蓝牙连接和数据发送
  66. */
  67. class BluetoothPrinter {
  68. constructor(options = {}) {
  69. this.deviceId = '';
  70. this.serviceId = '';
  71. this.characteristicId = '';
  72. this.writeType = 'write'; // 写入类型:'write' 或 'writeNoResponse'
  73. this.writeDelayMs = 0;
  74. this.transportProfile = null;
  75. this.sending = false;
  76. this.streamInterrupted = false;
  77. this.writeTimeoutMs = Number.isFinite(options.writeTimeoutMs) && options.writeTimeoutMs > 0
  78. ? options.writeTimeoutMs : 10000;
  79. this.isConnected = false;
  80. this.silent = options.silent || false; // 是否静默模式(不显示提示)
  81. }
  82. /**
  83. * 初始化蓝牙适配器
  84. * @returns {Promise}
  85. */
  86. async initBluetoothAdapter() {
  87. return new Promise((resolve, reject) => {
  88. uni.openBluetoothAdapter({
  89. success: (res) => {
  90. console.log('蓝牙适配器打开成功');
  91. resolve(res);
  92. },
  93. fail: (err) => {
  94. console.error('蓝牙适配器打开失败:', err);
  95. if (err.errCode === 10001 && !this.silent) {
  96. // 只有在非静默模式下才显示提示
  97. uni.showModal({
  98. title: '提示',
  99. content: '检测到蓝牙未打开,请打开蓝牙后重试',
  100. showCancel: true,
  101. confirmText: '知道了'
  102. });
  103. }
  104. reject(err);
  105. }
  106. });
  107. });
  108. }
  109. /**
  110. * 搜索蓝牙设备
  111. * @param {Function} onDeviceFound - 发现设备时的回调函数
  112. * @returns {Promise}
  113. */
  114. async searchDevices(onDeviceFound) {
  115. return new Promise((resolve, reject) => {
  116. uni.getBluetoothAdapterState({
  117. success: (res) => {
  118. if (!res.available) {
  119. reject(new Error('本机蓝牙不可用'));
  120. return;
  121. }
  122. if (res.discovering) {
  123. uni.stopBluetoothDevicesDiscovery({
  124. success: () => {
  125. this.startDiscovery(onDeviceFound, resolve, reject);
  126. }
  127. });
  128. } else {
  129. this.startDiscovery(onDeviceFound, resolve, reject);
  130. }
  131. },
  132. fail: reject
  133. });
  134. });
  135. }
  136. /**
  137. * 开始搜索设备
  138. */
  139. startDiscovery(onDeviceFound, resolve, reject) {
  140. // 监听寻找到新设备的事件
  141. uni.onBluetoothDeviceFound((devices) => {
  142. if (onDeviceFound) {
  143. onDeviceFound(devices);
  144. }
  145. });
  146. // 获取平台信息
  147. const systemInfo = uni.getSystemInfoSync();
  148. const isIOS = systemInfo.platform === 'ios';
  149. // iOS 端可能需要指定 services 参数,但先尝试不指定(通用蓝牙搜索)
  150. // 如果打印机支持标准 BLE 服务,可以尝试添加 services 参数
  151. // 常见的 ESC/POS 打印机服务 UUID: '0000ff00-0000-1000-8000-00805f9b34fb' 或 '000018f0-0000-1000-8000-00805f9b34fb'
  152. const discoveryOptions = {
  153. success: (res) => {
  154. console.log('开始搜索蓝牙设备', isIOS ? '(iOS)' : '(Android)');
  155. resolve(res);
  156. },
  157. fail: (err) => {
  158. console.error('搜索蓝牙设备失败:', err);
  159. // iOS 端如果失败,可能是权限问题或需要 services 参数
  160. if (isIOS) {
  161. console.warn('iOS 端搜索失败,可能需要检查蓝牙权限或指定服务 UUID');
  162. }
  163. reject(err);
  164. }
  165. };
  166. // iOS 端可以尝试添加 allowDuplicatesKey 参数,允许重复发现设备
  167. if (isIOS) {
  168. discoveryOptions.allowDuplicatesKey = false; // iOS 端建议设为 false
  169. }
  170. uni.startBluetoothDevicesDiscovery(discoveryOptions);
  171. }
  172. /**
  173. * 停止搜索设备
  174. */
  175. stopDiscovery() {
  176. return new Promise((resolve, reject) => {
  177. uni.stopBluetoothDevicesDiscovery({
  178. success: resolve,
  179. fail: reject
  180. });
  181. });
  182. }
  183. /**
  184. * 连接蓝牙设备
  185. * @param {String} deviceId - 设备ID
  186. * @returns {Promise}
  187. */
  188. async connect(deviceId) {
  189. return new Promise((resolve, reject) => {
  190. // 如果已经连接到同一个设备,直接返回成功
  191. if (this.isConnected && this.deviceId === deviceId && this.serviceId && this.characteristicId) {
  192. console.log('蓝牙已连接,无需重复连接');
  193. resolve();
  194. return;
  195. }
  196. if (this.sending) { reject(new Error('打印发送进行中,不能切换连接')); return; }
  197. this.transportProfile = null;
  198. uni.createBLEConnection({
  199. deviceId: deviceId,
  200. success: (res) => {
  201. console.log('蓝牙连接成功');
  202. this.deviceId = deviceId;
  203. this.isConnected = true;
  204. // 连接成功后获取服务,增加延迟确保设备完全准备好
  205. // iOS 端可能需要更长的延迟时间
  206. const systemInfo = uni.getSystemInfoSync();
  207. const isIOS = systemInfo.platform === 'ios';
  208. const delay = isIOS ? 500 : 300; // iOS 延迟 500ms,Android 延迟 300ms
  209. setTimeout(() => {
  210. this.getBLEServices(deviceId).then(resolve).catch(reject);
  211. }, delay);
  212. },
  213. fail: (err) => {
  214. // 如果错误是"already connect",当作成功处理
  215. if (err.errMsg && err.errMsg.includes('already connect')) {
  216. console.log('蓝牙已经连接');
  217. this.deviceId = deviceId;
  218. this.isConnected = true;
  219. // 已经连接的话,直接获取服务
  220. setTimeout(() => {
  221. this.getBLEServices(deviceId).then(resolve).catch(reject);
  222. }, 100);
  223. } else {
  224. console.error('蓝牙连接失败:', err);
  225. this.isConnected = false;
  226. reject(err);
  227. }
  228. }
  229. });
  230. });
  231. }
  232. /**
  233. * 获取蓝牙设备服务
  234. * @param {String} deviceId - 设备ID
  235. * @returns {Promise}
  236. */
  237. async getBLEServices(deviceId) {
  238. return new Promise((resolve, reject) => {
  239. uni.getBLEDeviceServices({
  240. deviceId: deviceId,
  241. success: (res) => {
  242. if (!res.services || res.services.length === 0) {
  243. reject(new Error('该蓝牙设备不可用'));
  244. return;
  245. }
  246. const servicesPromise = [];
  247. const candidates = [];
  248. for (let s = 0; s < res.services.length; s++) {
  249. const serviceId = res.services[s].uuid;
  250. const prom = new Promise((resolveService) => {
  251. uni.getBLEDeviceCharacteristics({
  252. deviceId: deviceId,
  253. serviceId: serviceId,
  254. success: (charRes) => {
  255. if (charRes.characteristics && charRes.characteristics.length > 0) {
  256. for (let c = 0; c < charRes.characteristics.length; c++) {
  257. const char = charRes.characteristics[c];
  258. candidates.push({
  259. serviceId,
  260. characteristicId: char.uuid,
  261. write: char.properties.write === true,
  262. writeNoResponse: char.properties.writeNoResponse === true
  263. });
  264. }
  265. }
  266. // 无论是否找到特征值,都 resolve,继续尝试下一个服务
  267. resolveService();
  268. },
  269. fail: (err) => {
  270. // 某些服务可能没有特征值(如系统服务),这是正常的,不视为错误
  271. // 继续尝试下一个服务
  272. console.warn(`服务 ${serviceId} 获取特征值失败:`, err.errMsg || err);
  273. resolveService();
  274. }
  275. });
  276. });
  277. servicesPromise.push(prom);
  278. }
  279. // 使用 Promise.allSettled 确保所有服务都尝试完成,即使某些失败也不影响
  280. Promise.allSettled(servicesPromise).then(() => {
  281. const selected = choosePrinterCharacteristic(candidates);
  282. if (selected) {
  283. this.transportProfile = null;
  284. this.serviceId = selected.serviceId;
  285. this.characteristicId = selected.characteristicId;
  286. this.writeType = selected.write ? 'write' : 'writeNoResponse';
  287. console.log('找到可写特征值:', this.serviceId, this.characteristicId,
  288. '写入类型:', this.writeType, '支持 write:', selected.write,
  289. '支持 writeNoResponse:', selected.writeNoResponse);
  290. resolve();
  291. } else {
  292. reject(new Error('未找到蓝牙打印数据通道,请确认设备支持 BLE ESC/POS 打印'));
  293. }
  294. });
  295. },
  296. fail: reject
  297. });
  298. });
  299. }
  300. /**
  301. * 断开蓝牙连接
  302. */
  303. disconnect() {
  304. this.transportProfile = null;
  305. if (this.deviceId && this.isConnected) {
  306. uni.closeBLEConnection({
  307. deviceId: this.deviceId
  308. });
  309. this.deviceId = '';
  310. this.serviceId = '';
  311. this.characteristicId = '';
  312. this.writeType = 'write'; // 重置写入类型
  313. this.writeDelayMs = 0;
  314. this.isConnected = false;
  315. }
  316. }
  317. /**
  318. * 发送打印数据
  319. * @param {ArrayBuffer} buffer - 打印数据缓冲区
  320. * @param {Function} onProgress - 进度回调函数 (current, total)
  321. * @returns {Promise}
  322. */
  323. setTransportProfile({ deviceId, maxWriteBytes = 20, writeDelayMs } = {}) {
  324. // 只能使用当前连接实际确认的有效负载上限,不能直接使用请求MTU。
  325. if (this.sending || !this.isConnected || deviceId !== this.deviceId ||
  326. !Number.isInteger(maxWriteBytes) || maxWriteBytes < 1 || maxWriteBytes > 509 ||
  327. (writeDelayMs !== undefined && (!Number.isFinite(writeDelayMs) || writeDelayMs < 0 || writeDelayMs > 1000))) {
  328. throw new Error('打印连接传输参数无效');
  329. }
  330. this.transportProfile = { deviceId, maxWriteBytes, writeDelayMs };
  331. }
  332. /** 中断后需要操作员清空/重启打印机;仅重连不代表设备的点阵缓冲已清空。 */
  333. confirmPrinterBufferCleared() {
  334. if (this.sending) throw new Error('打印发送进行中');
  335. this.streamInterrupted = false;
  336. }
  337. async sendPrintData(buffer, onProgress) {
  338. if (this.sending) throw new Error('打印发送进行中,请顺序发送');
  339. if (this.streamInterrupted) throw new Error('打印流已中断,请清空打印机缓冲后确认恢复');
  340. if (!this.isConnected || !this.deviceId || !this.serviceId || !this.characteristicId) throw new Error('蓝牙未连接');
  341. if (!(buffer instanceof ArrayBuffer)) throw new Error('打印数据必须是ArrayBuffer');
  342. const profile = this.transportProfile?.deviceId === this.deviceId ? this.transportProfile : null;
  343. const maxChunk = profile?.maxWriteBytes || 20;
  344. const total = buffer.byteLength;
  345. const isIOS = /^(ios|iphone|ipad)$/.test(uni.getSystemInfoSync().platform);
  346. const delayMs = profile?.writeDelayMs ?? Math.max(isIOS ? 20 : 10, this.writeDelayMs);
  347. let lastProgressAt = 0;
  348. this.sending = true;
  349. try {
  350. for (let offset = 0; offset < total; offset += maxChunk) {
  351. const end = Math.min(offset + maxChunk, total);
  352. await this.writeBLECharacteristicValue(buffer.slice(offset, end));
  353. // 未实测设备保留原安全间隔,包括末包,避免下一分段立即写入。
  354. if (delayMs) await new Promise(resolve => setTimeout(resolve, delayMs));
  355. if (onProgress && (end === total || Date.now() - lastProgressAt >= 100)) {
  356. lastProgressAt = Date.now();
  357. // UI异常不能触发重发或截断打印数据。
  358. try { onProgress(end, total); } catch (error) { console.warn('打印进度回调失败', error); }
  359. }
  360. }
  361. } catch (error) {
  362. // 失败回调不能证明设备未收到数据,禁止自动重放导致像素/指令错位。
  363. this.streamInterrupted = true;
  364. throw error;
  365. } finally {
  366. this.sending = false;
  367. }
  368. }
  369. /** 串行写入并设置超时;迟到回调不再改变任务结果。 */
  370. writeBLECharacteristicValue(buffer) {
  371. return new Promise((resolve, reject) => {
  372. if (!this.isConnected || !this.deviceId || !this.serviceId || !this.characteristicId) {
  373. reject(new Error('蓝牙未连接,无法写入数据'));
  374. return;
  375. }
  376. let settled = false;
  377. const finish = (error, result) => {
  378. if (settled) return;
  379. settled = true;
  380. clearTimeout(timer);
  381. if (error) reject(error); else resolve(result);
  382. };
  383. const timer = setTimeout(() => finish(new Error('蓝牙写入超时,请检查打印机')), this.writeTimeoutMs);
  384. try {
  385. uni.writeBLECharacteristicValue({
  386. deviceId: this.deviceId, serviceId: this.serviceId,
  387. characteristicId: this.characteristicId, value: buffer, writeType: this.writeType,
  388. success: res => {
  389. if (!res?.errMsg || res.errMsg.includes('ok')) finish(null, res);
  390. else finish(new Error(res.errMsg));
  391. },
  392. fail: error => finish(error)
  393. });
  394. } catch (error) { finish(error); }
  395. });
  396. }
  397. /**
  398. * 从本地存储加载设备ID
  399. */
  400. loadDeviceFromStorage() {
  401. const deviceId = uni.getStorageSync('iasei-bluetooth-id');
  402. if (deviceId) {
  403. this.deviceId = deviceId;
  404. }
  405. return deviceId;
  406. }
  407. /**
  408. * 保存设备ID到本地存储
  409. * @param {String} deviceId - 设备ID
  410. */
  411. saveDeviceToStorage(deviceId) {
  412. uni.setStorageSync('iasei-bluetooth-id', deviceId);
  413. this.deviceId = deviceId;
  414. }
  415. }
  416. export default BluetoothPrinter;