古茗前端打印机技术的演进

本文介绍了古茗热敏打印机技术的演进,包括其原理、组成和原材料。回顾了该技术经历的五次迭代,类比为人类发展史的五个时代,介绍了不同时代的打印方案,如驱动打印、文字指令打印等,并分析了各方案的优缺点,还提及打印监控及相关实践和问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

大厂技术  高级前端  Node进阶
点击上方 程序员成长指北,关注公众号
回复1,加入高级Node交流群

前言

蛮有意思的打印小票,可以把这个技术扩展一下,比如打印单词等。

本篇主要介绍了古茗打印机技术的演进。首先解释了热敏打印机的原理,即通过温度控制打印内容,不需要墨水。然后介绍了热敏打印机的组成和原材料。接着,回顾了古茗打印技术经历的五次迭代,类比为人类发展史的石器、青铜、农业、工业、信息时代。然后,提到了门店收银机上打印小票和杯贴的需求,并介绍了不同时代的打印方案。最后,总结了驱动打印的不足之处,并提出了改进的方向。

今日前端早读课文章由 @蔡钧分享,公号:Goodme 前端团队投稿授权。

正文从这开始~~

一句话概括一下热敏打印机:通过温度控制打印内容。

不要在碰到一些小票打印出来模糊的时候说没墨了,他根本没有墨!核心在纸,这就是为什么我们点外卖或者堂食给到我们的小票用指甲划一下就能变黑。

热敏打印机原理

热敏打印技术的关键在于加热元件,热敏打印机芯上有一排微小的半导体元件,这些元件排得很密,从 200DPI 到 600DPI 不等,这些元件在通过一定电流时,会很快产生高温,当热敏纸的涂层遇到这些元件时,在极短时间内温度就会升高,热敏纸上的涂层就会发生化学反应,显现色彩。

3fba3aabe18039de7597b510be261d56.gif

这个涂层的主要成分是双酚 A,学名 2,2 - 二 (4 - 羟基苯基) 丙烷,简称 BPA。

658484d201adf7115fcaf42fe918ac6b.png

插句话,这个玩意儿属低毒性化学物,比较有争议性。2011 年 5 月 30 日,卫生部等 6 部门对外发布公告称,鉴于婴幼儿属于敏感人群,为防范食品安全风险,保护婴幼儿健康,禁止双酚 A 用于婴幼儿奶瓶。

热敏纸一般主要由三部分组成:防护层、变色层、纸基。有单防、三防、五防等等。。古茗的标签用的是三防(指甲划过不留痕),票据用的单防(指甲划过留痕)

1502b1bd4211a37c6bd9498d9a4843ce.png

快速浏览指南

古茗打印技术经历了 5 次迭代,就以人类发展史来类比:石器、青铜、农业、工业、信息时代。所有代码已精简。

干货较多,快速浏览可以只看

  • 背景

  • 各个时代的打印效果和结论

  • 我对这个行业的一些吐槽

背景

门店收银机上来订单的时候需要打印出对应的小票和杯贴。

设备

门店收银机:windows 系统,在出厂时已经安装好小票(票据)和杯贴(条码)打印机驱动。

门店打印机:门店有两台打印机,连接方式 99% 是 usb,剩下 1% 是网线连接。

系统

开发框架:electron

结构

4a603002347168b0fbe3fd02249eb298.png

打印方案落地
石器时代(驱动打印)

需求:能打印就行

方案:采用 electron 直接调用驱动打印,提前准备好打印模板文件(React class 组件),用 umd 打包,模板在 render 层生成。

链路:收到打印信息 -> printer 进程开始拉取对应模板 -> 结合 socket 消息的数据结合模板生成真实模板 -> 调用打印方法

实践:

1、收到消息后给 printer 进程发送打印消息

this.window.webContents.send(ipcEvent.SET_PRINT_DATA, printData, traceId);

2、printer 进程接受消息后开始渲染模板,渲染完成后让 main 执行打印动作(EXEC_PRINT)

import { useEffect, useState } from 'react';
 import AsyncComponent from './AsyncComponent'

 interface PrintData {
   templateUrl: string // 模板
   data: PrintDataInfo // 数据
   deviceInfo: PrintDeviceInfo // 打印机相关信息
 }

 export default () => {
   const [templateUrl, setTemplateUrl] = useState('');
   const [templateProps, setTemplateProps] = useState({});

   useEffect(() => {
     ipcRenderer.on(ipcEvent.SET_PRINT_DATA, (
       _e: Electron.IpcRendererEvent,
       printData: PrintData,
       traceId: TraceID
     ) => {
       const { templateUrl, data, deviceInfo } = printData;
       setTemplateUrl(templateUrl);
      setTemplateProps(() => data);
       // 在子组件渲染完毕后执行打印方法,先用一个setTimeout模拟一下
       setTimeout(() => {
         ipcRenderer.send(
           ipcEvent.EXEC_PRINT,
           deviceInfo.deviceName,
           traceId
         );
       }, 200)
     });
   })

   return templateUrl ? (
     <AsyncComponent
       data={data}
       templateUrl={templateUrl}
     /> : null
   )
 }
import loadjs from 'loadjs';
 import React, { useEffect, useState } from 'react';

 export default ({ templateUrl, data = {} }) => {
   const [Comp, setComp] = useState<React.ReactNode | null>(null);

   useEffect(() => {
     // 传入的templateUrl为D:/XiaoPiao/XiaoPiao.js
     const name = templateUrl.split('/')[1];

     const componentName = `Micro_${name}`; // umd打包规则是前面加上前缀 Micro_

     if (window[componentName]) {
       setComp(() => window[componentName]);
       return;
     }

     // 通过loadjs动态加载js文件
     loadjs(`${name}?timespan=${Date.now()}`, {
       success: () => {
         setComp(() => window[componentName]);
       },
       error: (error) => {
         console.log(error);
       },
     });

     return () => {
       window[componentName] = null;
     };
   }, [templateUrl]);

   return Comp ? (
     <Comp
       data={data}
     />
   ) : null;
 };

3、main 执行打印方法

ipcMain.on(ipcEvent.EXEC_PRINT, (_, deviceName, traceId) => {
   this.window.webContents.print(
       {
         silent: true,
         printBackground: true,
         copies: 1,
         deviceName
       },
       (success, failReason) => {
         logger.info(Lable.打印, traceId, deviceName, '打印结果', success, failReason);
         if (success !== true) {
           logger.error(Lable.打印, '', '打印失败', '驱动打印异常', failReason);
         }
       }
     );
 });

效果

d1e8a49870d06152b0e393b56996d516.png

d35c7d70dabae4a13061a694b3779060.png

问题

  • 但是效果不佳,杯贴打印机会偏移、打不全,需要修改驱动中的卷、浓度等参数才能打印正常

  • 打印模糊

  • 打印速度很慢,从点击打印到真实打出来需要隔好几秒

结论

驱动打印不行。

在前期的门店测试中,发现这个打印方式并不好,我们需要手动对门店的打印驱动进行设置才能让打印正常,如果全国所有门店都需要这样操作过一次投入的人力成本会很大,而且驱动打印的打印速度慢影响门店经营。

青铜时代(文字指令打印)

需求:无需人工配置驱动参数,提高打印速度。

方案:通过 node-escpos-win (npm 包) 获取并发送数据给打印机,通过 escpos (npm 包) 生成 16 进制数据,提前准备 cjs 模板,模板在 node 层生成。

链路:收到打印信息 -> printer 进程拉取 cjs 模板 -> 结合 socket 消息的数据结合模板生成 16 进制数据 -> 发送数据到打印机。

知识点:票据 ESC/POS 指令,条码 TSPL 指令。

实践:

1、收到消息后主进程拉取模板并生成数据

const getPrinterBuffer = async (printData, traceId) => {
  const { templateUrl, data } = printData;
   // templateUrl: D:/XiaoPiao/XiaoPiao.js
   const command = require(templateUrl);

   const buffer = await command({
     data
   })

   return buffer
 }
const command = async (data => {
   const cmd = require('escpos');

   const p = new cmd.Printer(
     "",
     {
       encoding: "gbk",
       width: 48,
     }
   );

   // 编写模板
   p.newLine();
   p.size(2, 2).align("lt").text(`${data.number}号 ${data.type}`);
   p.size(1, 1);
   p.text(data.shopName).text(data.time);

   // ...中间有一大坨模板相关代码

   // 执行切纸
   p.cut()

  return p.buffer._buffer;
 })

 module.exports = command;

2、将 buffer 推送给指定的 usb 设备,由于我们只知道从哪台驱动打印机出,所以要从驱动打印找到端口再找到正确 usbpath

b5dd4f157b27582b39852931f3bab3f1.png

c0c2c245b06b3dc099d21a017bef6b92.png

利用 wmic 可以获取到打印机驱动对应的端口以及 usb 设备的端口

// 获取打印机驱动的信息
 const getPrinter = () => {
   const wmic = require('wmic-js');
   return new Promise<Win32Printer.Printer[]>((res, rej) => {
       wmic()
         .alias('printer')
         .get('Name', 'printerState', 'printerStatus', 'WorkOffline', 'PortName')
         .then((data: Win32Printer.Printer[]) => {
           res(data);
         })
         .catch((err: unknown) => {
           rej(err);
         });
     });
 }

 // 获取每个端口上的usb设备是什么
 // 这个命令会包含usbprint设备而上一条就是这个usb设备的usbPath

 const printPortMap = () => {
   return new Promise<Record<string, string>>((res, rej) => {
     exec('wmic path Win32_USBControllerDevice get Dependent /format:list', (err, stdout) => {
       if (err) {
         rej(err);
         return;
       }
       const usbList: string[] = [];
       const map: Record<string, string> = {};
       const lines = stdout.split('\r\r\n');
       lines.forEach((line) => {
         if (line.startsWith('Dependent=')) {
           const usb = line.replace('Dependent=', '');
           usbList.push(usb);
         }
       });
       for (let i = 0; i < usbList.length; i++) {
         if (usbList[i].indexOf('USBPRINT') > -1) {
           const line = usbList[i].replace(/"/g, '');
           const portName = line.substr(line.length - 6);
           const usbPath = usbList[i - 1].replace(/&amp;/g, '&');
           if (portName.indexOf('USB') > -1) {
             map[portName] = usbPath;
           }
         }
       }
       res(map);
     });
   });
 };

 // 打印方法
 const print = async (buffer, deviceName, traceId) => {
   const printList = await getPrinter();
   const portMap = await printPortMap();
   const escpos = require('node-escpos-win');

   // 这里获取到的usbList里就会有跟portMap中usbPath一样的设备
   const usb = escpos.GetDeviceList('USB');
   const usbList = usb.list.filter(
     (item) => item.service === 'usbprint' || item.name === 'USB 打印支持'
   );

   printList.forEach(item => {
     if (item.name === deviceName) {
       const usbDevice = usbList.find(item => {
         return item.path.indexOf(portMap[item.portName]) !== -1
    })
       const res = escpos.Print(usbDevice.path, buffer);
       logger.info(String(res), traceId);
       escpos.Disconnect(usbDevice.path);
     }
   })
 }

效果

c8f5609e626b3cac1972fa5e4a054d2a.png

aecffa86b851ee40d3e6ede56d2b3056.png

结论

指令打印好啊,太好了,打印清晰、流畅、速度快,而且不需要配置打印机驱动。

问题就是只能打宋体,而且字体大小都是预设好的没办法调整,不过至少能保证门店正常经营了,先推吧~

农业时代(图片指令打印)

插曲:

全国门店都替换完指令打印这套方案后的一段时间。。。

(敲桌子)"你们这个东西也太丑了吧,谁设计的站出来"

我站了起来:"这个打印机打出来就是这样子的,你不信你看外卖点的单子是不是都长这样"

(更加愤怒的敲桌子)"这么丑,品牌形象都没了,改!"

我死死的盯着他:"改!就!改!"

背景:指令打印太丑,并且业务方想在” 上新 “、” 季节性活动 “时增加品宣信息

方案:ESC/POS、TSPL 均采用图片指令的形式,提前准备 cjs 模板,模板在 render 层生成

链路:收到打印信息 -> render 进程拉取 cjs 模板 -> 结合 socket 消息的数据用 canvas 绘制 -> 将图片处理成打印机需要的格式 -> 发送数据到打印机

前置知识:

打印机如何打印图片。

票据打印机的指令和条码打印机的指令对于打印图片的格式要求基本都相似,所以就举例其中之一进行讲解。

看看 ESC/POS 指令的文档

c9ff43bdadaa727ec82bc5a06635472b.png

0325a7f2780d5c3c34d168f07455d151.png

。。看不懂,干脆直接试试好了,从如何打印一个像素的小黑点开始。

注意到 x 的最小单位是字节数,而一个字节等于 8 个比特也就是说如果其实我能一次性控制 8 个点的打印。

fa2c5cec4d2e543f65d04cf2c9a8e20a.png

所以打印一个小黑点的指令就得出是:1D 76 30 00 01 00 01 00 80

所以按这个公式理论上可以在一张小票纸上的任意位置打出黑点。ok 那么开始实践

实践:

1、还是从打印一个黑点开始,用到 get-pixels 这个库可以获取到图片的宽高以及每个像素点的 rgba。提前准备好一张宽高均为 1px 的全黑图片 "dot.png"。

// 目标是得到Buffer 1D 76 30 00 01 00 01 00 80

 const escpos = require('node-escpos-win');
 const getPixel = require('get-pixels');

 const usb = escpos.GetDeviceList('USB');

 const list = usb.list.filter((item) => item.service === 'usbprint' || item.name === 'USB 打印支持');

 const printer = list[0];

 getPixel('./dot.png',(err, { data, shape }) => {
   // data: [0, 0, 0, 255]
   // shape: [1, 1, 4]
   const imgData = rgba2hex(data, shape);
   const width = shape[0];
   const height = shape[1];
   const xL = Math.ceil((width / 8) % 256);  // 1
   const xH = Math.floor((width / 8) / 256); // 0
   const yL = height % 256; // 1
   const yH = Math.floor(height / 256); // 0
   const buffer = Buffer.from([0x1d, 0x76, 0x30, 0, xL, xH, yL, yH, ...imgData]);

   const res = escpos.Print(printer.path, buffer);
 })

 const rgba2hex = (arr, shape) => {
   const bitArr = [];
   for (let i = 0; i < data.length; i = i + 4) {
     if (i[3] === 0) {
       bitArr.push(0);
       continue;
     }
     // 计算平均值判断
     const bit = (data[i] + data[i + 1] + data[i + 2]) / 3 > 160 ? 0 : 1;
     bitArr.push(bit);
   }
   // bitArr: [1]
   // 对bitArr做补0的动作
   const newBitArr = [];
   const width = shape[0];
   const isNeed = width % 8 !== 0;
   const height = shape[1];
   if (isNeed) {
     for (let i = 0; i < height; i++) {
       newBitArr.push(...bitArr.slice(i * width, (i + 1) * width));
       for (let j = 0; j < 8 - (width % 8); j++) {
         newBitArr.push(0);
       }
     }
   } else {
     newBitArr = bitArr;
   }
  // newBitArr: [1, 0, 0, 0, 0, 0, 0, 0]
   const byteArr = [];
   for (let i = 0; i < newBit.length; i = i + 8) {
     const byte =
       (newBit[i] << 7) +
       (newBit[i + 1] << 6) +
       (newBit[i + 2] << 5) +
       (newBit[i + 3] << 4) +
       (newBit[i + 4] << 3) +
       (newBit[i + 5] << 2) +
       (newBit[i + 6] << 1) +
       newBit[i + 7];
     byteArr.push(byte);
   }
   // byteArr: [128] = [0x80];
   return new Uint8Array(byteArr);
 }

成功打印了但是像素太小,所以另外准备一张图片试试并且贴心的给不在工位的同事贴上

8454c2f59935fccc0e7d3a90383666a6.png

后面考虑到图片也是我们自己生成的,所以只要提前保证图片的宽度像素是 8 的倍数就行,能省去 "补 0" 的操作。

2、模板文件通过 canvas 绘制图片

// 因为是在electron里,所以渲染进程可以用cjs

 // 票据模板的高度是动态变化的,所以用一个简单粗暴的方式,计算高度,然后再渲染

 module.exports = (data) => {
   const canvas = document.createElement("canvas");
   canvas.width = 576;
   let canvasY = 0;
   const drawList = [];
   const headerY = canvasY;
   drawList.push(() => {
     ctx.font = "40px sans-bold";
     ctx.fillStyle = "#231815";
     ctx.fillText(data.number, 0, headerY + 52);
   });

   canvasY += 64;

   const bodyY = canvasY;
   drawList.push(() => {
     ctx.font = "24px sans-bold";
     ctx.fillStyle = "#231815";
     ctx.fillText(data.shopName, 0, bodyY + 24);
   });

   canvasY += 24;

   // ... 一大堆画画代码

   canvas.height = canvasY;
   ctx.fillStyle = "white";
   ctx.fillRect(0, 0, 576, canvasY); // 将背景设置成白色

   for (let i = 0; i < drawList.length; i++) {
     drawList[i]();
   }

   return canvas.toDataURL();
 }

3、在主进程中获得渲染后的 base64 进行打印即可

// 渲染进程中
 const command  = require(templateUrl);
 const buffer = command(data);

 // 将buffer交给主进程处理
 const getPixel = require('get-pixels');
 getPixel(buffer,(err, { data, shape }) => {
   const imgData = rgba2hex(data, shape);
   const width = shape[0];
   const height = shape[1];
   const xL = Math.ceil((width / 8) % 256);
   const xH = Math.floor((width / 8) / 256);
   const yL = height % 256;
   const yH = Math.floor(height / 256);
   const buffer = Buffer.from([0x1d, 0x76, 0x30, 0, xL, xH, yL, yH, ...imgData]);

   const res = escpos.Print(printer.path, buffer);
 })

效果

dae04866842c0d4d253ba9145278f84c.png

91578a62e14cde5bdeb09447a4224e6d.png

结论

图片指令打印行。

通过这个打印方式可以控制这张纸上能被热敏头接触到的任意地方都能打印出自己想要的信息,所以可以说是最好的打印方式,但唯一不足的是打印速度由于数据量的增加所以稍微慢了一点点但在接受范围内,打印方案以此为终点。

不过我们依然保留了驱动和文字指令两种打印方式,以便在出现门店出现问题时快速切换保证打印的正常。

tip:某些打印机厂商的打印机没有好好实现规范于是按上述的图片指令打印方案图片高度超过一定程度就会出现乱码,需要把这张图片拆分成多张小图片才能正常打。

// 渲染进程中
 const command  = require(templateUrl);
 const buffer = command(data);

 // 将buffer交给主进程处理
 const getPixel = require('get-pixels');
 getPixel(buffer,(err, { data, shape }) => {
   const imgData = rgba2hex(data, shape);
   const width = shape[0];
   const height = shape[1];
   const xL = Math.ceil((width / 8) % 256);
   const xH = Math.floor((width / 8) / 256);
  const buffer = [];
   for(let h=0;h<height;h++) {
     buffer.push(...[0x1d, 0x76, 0x30, 0, xL, xH, 1, 0, imgData.slice(i*(xL + 256*xH), (i+1)*(xL + 256*xH))]);
   }

   const res = escpos.Print(printer.path, Buffer.from(buffer));
 })
工业时代(打印机全配置化)

背景:业务方会以高频率来修改模板其中的品宣内容,并且需要根据区域或指定门店进行宣传,门店打印机种类繁多,需要对打印进行设置

方案:打印模板建站(电子菜单相同方案,直接 CV)

7be83c81e4ab3f4779f58cb046a29dbd.png

业务流程:运营人员新建打印模板 - 在打印模板建站页里对模板调调改改 - 生成模板配置进行模板资源快照 (rollup 编译,编译完成后上传资源到 oss) - 下发资源给门店 - 门店生效

相关代码:

对模板项目的改造,核心是给本次编译传入 templateConfig 生成 js 文件

import replace from "@rollup/plugin-replace";

 const templateMap = {
   "LABEL": "BiaoQian",
   "TICKET": "XiaoPiao",
 };

 const template = process.env.template;
 const templateConfig = process.env.templateConfig || "null";
 const buildEnv = process.env.ENV || "dev";
 const token = process.env.Token;

  const buildTemplate = template
     ?.split(",")
     ?.map((item) => templateMap[item])
     ?.join("|");

   if (!buildTemplate?.length) {
     throw new Error("请指定打包模板");
   }

 const files = glob.sync(
   `./src/package/@(${buildTemplate})**/index.@(tsx|ts)`,
   {}
 );

 const hash = `/${Math.random().toString(36).substring(2, 20)}/`;

 const entryName = file.slice(14, file.lastIndexOf("/"));

 export default {
   return {
     input: { [entryName]: file },
     output: {
       sourcemap: true,
       format: file.indexOf("tsx") > -1 ? "umd" : "cjs",
       dir: `dist${hash}${entryName}`,
       entryFileNames: `${entryName}.js`,
       name: `Micro_${entryName}`,
     },
    plugins: [
       replace({
        "process.env.templateConfig": templateConfig,
      })
     ]
   };
 }
module.export = (data, printConfig) => {
   const templateConfig = process.env.templateConfig || {
     logoUrl: "",
     brandUrl: "",
     /// ...
   };

   const canvas = document.createElement("canvas");
   // ...原本的模板代码

   // 真实生成的图片可以通过传入的打印设置进行位置&大小调整
  const { offsetX, offsetY, width, height } = printConfig;

   const scaleCanvas = document.createElement("canvas");
   scaleCanvas.width = printConfig.width;
   scaleCanvas.height = printConfig.height;
   const scaleCtx = scaleCanvas.getContext("2d");
   // 白色背景
   scaleCtx.fillStyle = "white";
   scaleCtx.fillRect(0, 0, scaleCanvas.width, scaleCanvas.height);
   scaleCtx.drawImage(
     canvas,
     offsetX + 8,
     offsetY,
     scaleCanvas.width,
     scaleCanvas.height
   );

   return scaleCanvas.toDataURL();
 }

结论

def112e1416b9de59a1a40f5ebfa883f.png

通过打印模板建站释放了开发人员,后续只需要在建站页上进行迭代即可,日常相关的改动都可以由运营同学完成,并且不影响门店对打印机的设置。

信息时代(打印监控)

背景:门店的收银机和打印机的连接存在不稳定的情况,例如 USB 端口松动、usb 线老化、打印机老化等等原因可能导致门店的连接不稳定出现漏单、乱码等情况出现。

方案:与打印机供应商协调开发,在 USB 通道上增加消息通信完成软硬件之间的打印监控,以小票举例,以切纸指令为结束符,打印机执行后回一个完成消息。

链路:查询打印机版本是否支持 - 若支持开启监控能力 - 每次打印结束后以切纸为结束约定 - 打印机执行切纸指令后在 usb 通道上返回一个消息 - 判断消息监控打印机是否打印完成

实践:

找了一圈都没发现 node 有什么库可以比较方便的跟 USB 设备做通信,于是决定自己撸一个。

#![deny(clippy::all)]

 use std::ffi::CString;
 use napi::bindgen_prelude::Buffer;
 use std::mem::zeroed;
 use std::ptr::null_mut;
 use winapi::shared::minwindef::{DWORD, FALSE, TRUE};
 use winapi::shared::ntdef::NULL;
 use winapi::shared::winerror::{ERROR_IO_INCOMPLETE, ERROR_IO_PENDING};
 use winapi::um::errhandlingapi::GetLastError;
 use winapi::um::fileapi::{CreateFileA, ReadFile, WriteFile, OPEN_EXISTING};
 use winapi::um::handleapi::CloseHandle;
 use winapi::um::ioapiset::GetOverlappedResult;
 use winapi::um::minwinbase::OVERLAPPED;
 use winapi::um::winbase::{FILE_FLAG_NO_BUFFERING, FILE_FLAG_OVERLAPPED};
 use winapi::um::winnt::{
   FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ, FILE_SHARE_WRITE, GENERIC_READ, GENERIC_WRITE,
 };

 #[macro_use]
 extern crate napi_derive;

 #[napi]
 pub fn send_usb(path: String, buffer: Buffer) -> String {
   let path = CString::new(path).unwrap();
   let access = GENERIC_READ | GENERIC_WRITE;
   let share_mode = FILE_SHARE_READ | FILE_SHARE_WRITE;
   let creation_disposition = OPEN_EXISTING;
   let flags_and_attributes = FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED | FILE_FLAG_NO_BUFFERING;
   let handle = unsafe {
     CreateFileA(
       path.as_ptr(),
       access,
       share_mode,
       null_mut(),
       creation_disposition,
       flags_and_attributes,
       NULL,
     )
   };

   // 发送并接收数据
   let mut overlapped = unsafe { zeroed::<OVERLAPPED>() };
   let mut bytes_written: DWORD = 0;
   let mut bytes_read: DWORD = 0;

   let mut res_buffer: Vec<u8> = vec![0; 1024];
   let mut ret = unsafe {
     WriteFile(
       handle,
       buffer.as_ptr() as *const _,
       buffer.len() as u32,
       &mut bytes_written,
       &mut overlapped,
     )
   };
   if ret == FALSE {
     let err = unsafe { GetLastError() };
     if err != ERROR_IO_PENDING && err != ERROR_IO_INCOMPLETE {
       return format!("err: {:?}", err);
     }
   }
   ret = unsafe { GetOverlappedResult(handle, &mut overlapped, &mut bytes_written, TRUE) };
   if ret == FALSE {
     return format!("err: {:?}", unsafe { GetLastError() });
   }

   ret = unsafe {
     ReadFile(
       handle,
       res_buffer.as_mut_ptr() as *mut _,
       res_buffer.len() as u32,
       &mut bytes_read,
       &mut overlapped,
     )
   };
   if ret == FALSE {
     let err = unsafe { GetLastError() };
     if err != ERROR_IO_PENDING && err != ERROR_IO_INCOMPLETE {
       return format!("err: {:?}", err);
     }
   }

   ret = unsafe { GetOverlappedResult(handle, &mut overlapped, &mut bytes_read, TRUE) };

   if ret == FALSE {
     return format!("err: {:?}", unsafe { GetLastError() });
   }
   unsafe {
     CloseHandle(handle);
   }

   res_buffer.truncate(bytes_read as usize);

   let res_str = String::from_utf8_lossy(&res_buffer).to_string();

   res_str
 }

构建出 32 位和 64 位的包

f50605e5450ac61706518ec41b556085.png

2、在 electron 中引入试试

const { sendUsb } = require('./index.js');
 const escpos = require('node-escpos-win');
 const usb = escpos.GetDeviceList('USB');
 const getPixel = require('get-pixels');

 const list = usb.list.filter(
   (item) =>
     item.service === 'usbprint' ||
     item.name === 'USB 打印支持' ||
     item.name === 'USB Device Driver for POS/KIOSK Printers'
 );

 const printer = list[0];

 getPixel('./dot.png',(err, { data, shape }) => {
   const imgData = rgba2hex(data, shape);
   const width = shape[0];
   const height = shape[1];
   const xL = Math.ceil((width / 8) % 256);
   const xH = Math.floor((width / 8) / 256);
   const yL = height % 256;
   const yH = Math.floor(height / 256);
   const buffer = Buffer.from([0x1d, 0x76, 0x30, 0, xL, xH, yL, yH, ...imgData, 0x1d, 0x56, 65, 0]);

  const res = sendUsb(printer.path, buffer);

   console.log(res); // complete
 })

结论

可以通过 napi 的形式引入高级语言写的通信模块来实现打印机与 usb 通信的能力,在此基础上就能配合 usb 通道的返回做队列和漏单时的补偿逻辑,实现监控能力。

这块其实也有很多种实现,我们目前采用启动两个线程,一个线程专门写,另一个线程持续去读,这样把某些同步的指令全都以异步的消息来接收。

感想

在这套方案落地的过程中有一些比较有趣的事

  • 我们有一个小进程去判断 usb 设备的插拔,当替换 usb 打印机时自动去设置驱动里的端口,实现即插即用。

  • napi 的使用在某些老的 windows 版本上需要打系统补丁。

  • 打印机会受环境影响,某些强磁场环境下信号会丢失导致打印出问题(漏单、重复单、乱码等)。

  • 提升沟(chao)通(jia)能力。

  • 一些 windows api(主要是 kernel32)和热敏打印机原理相关的知识还是很有趣的。

想要让打印机打印出我们想要的东西,实际上只需要做好 保证 16 进制数据正确 和 保证数据能正常传输给设备 这两点就够了,本文只是举例了在 windows 上 usb 连接该如何做,明确了这两点,对症下药,就会发现跟设备打交道其实很容易。

吐槽

虽然说从软件角度把整套监控链路已经做起来了,但想要排查门店问题,实际上还受很多硬件的影响,例如当出现打印问题的时候,我们只能排查上位机是否有问题而不能排查数据线、打印机、电压、磁场干扰等众多变量,只能一个个去替换排查,导致排错的效率较低,就这点而言热敏打印机还是有很长的一段路可以走的。

另外想说一点,比较大的打印机厂商都会探索出自己认为最合适的指令规范,这就导致有很多厂商为了自己的打印机能无缝让用户从其他厂商的产品切换自己的产品,兼容适配其他厂商的指令规范不断往自己的打印机上加各种适配。听说 TSC 有一堆祖传代码,如果以后有机会自己做打印机了,一定要好好见识一下。

Node 社群

 
 

我组建了一个氛围特别好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你对Node.js学习感兴趣的话(后续有计划也可以),我们可以一起进行Node.js相关的交流、学习、共建。下方加 考拉 好友回复「Node」即可。

141add9bb6c5775b98a35f23d07a66f6.png

“分享、点赞、在看” 支持一下
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值