作者:蔡钧

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

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

热敏打印机原理

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

2024-03-05T09:16:02.png

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

2024-03-05T09:16:23.png

插句话,这个玩意儿属低毒性化学物,比较有争议性。2011年5月30日,卫生部等6部门对外发布公告称,鉴于婴幼儿属于敏感人群,为防范食品安全风险,保护婴幼儿健康,禁止双酚A用于婴幼儿奶瓶。
热敏纸一般主要由三部分组成:防护层、变色层、纸基。有单防、三防、五防等等。。古茗的标签用的是三防(指甲划过不留痕),票据用的单防(指甲划过留痕)

2024-03-05T09:17:00.png

快速浏览指南

古茗打印技术经历了5次迭代,就以人类发展史来类比:石器、青铜、农业、工业、信息时代。所有代码已精简。
干货较多,快速浏览可以只看

  1. 背景
  2. 各个时代的打印效果和结论
  3. 我对这个行业的一些吐槽

背景

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

设备

门店收银机:windows系统,在出厂时已经安装好小票(票据)和杯贴(条码)打印机驱动。
门店打印机:门店有两台打印机,连接方式99%是usb,剩下1%是网线连接。

系统

开发框架:electron

结构

2024-03-05T09:19:31.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);
        }
      }
    );
});

效果

2024-03-05T09:26:48.png
2024-03-05T09:28:07.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

2024-03-05T09:31:54.png
2024-03-05T09:32:09.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);
    }
  })
}

效果

2024-03-05T09:33:31.png
2024-03-05T09:33:48.png

结论

指令打印好啊,太好了,打印清晰、流畅、速度快,而且不需要配置打印机驱动。
问题就是只能打宋体,而且字体大小都是预设好的没办法调整,不过至少能保证门店正常经营了,先推吧~

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

插曲:

全国门店都替换完指令打印这套方案后的一段时间。。。
(敲桌子)"你们这个东西也太丑了吧,谁设计的站出来"
我站了起来:"这个打印机打出来就是这样子的,你不信你看外卖点的单子是不是都长这样"
(更加愤怒的敲桌子)"这么丑,品牌形象都没了,改!"
我死死的盯着他:"改!就!改!"

背景:

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

方案:

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

链路:

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

前置知识:

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

2024-03-05T09:35:09.png

。。看不懂,干脆直接试试好了,从如何打印一个像素的小黑点开始。
注意到x的最小单位是字节数,而一个字节等于8个比特也就是说如果其实我能一次性控制8个点的打印。

2024-03-05T09:35:29.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);
}

成功打印了但是像素太小,所以另外准备一张图片试试并且贴心的给不在工位的同事贴上
2024-03-05T09:37:16.png
2024-03-05T09:37:48.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);
})

效果

2024-03-05T09:38:57.png
2024-03-05T09:39:15.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)

2024-03-05T09:42:26.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();
}

结论:

2024-03-05T09:44:57.png

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

信息时代(打印监控)

背景:

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

方案:

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

链路:

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

实践:

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

1. 采用rust编写相关通信代码,用napi-rs打包成node能直接require的模块。
#![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位的包

2024-03-05T09:45:25.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通道的返回做队列和漏单时的补偿逻辑,实现监控能力。
这块其实也有很多种实现,我们目前采用启动两个线程,一个线程专门写,另一个线程持续去读,这样把某些同步的指令全都以异步的消息来接收。

感想

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

  1. 我们有一个小进程去判断usb设备的插拔,当替换usb打印机时自动去设置驱动里的端口,实现即插即用。
  2. napi的使用在某些老的windows版本上需要打系统补丁。
  3. 打印机会受环境影响,某些强磁场环境下信号会丢失导致打印出问题(漏单、重复单、乱码等)。
  4. 提升沟(chao)通(jia)能力。
  5. 一些windows api(主要是kernel32)和热敏打印机原理相关的知识还是很有趣的。

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

吐槽

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

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

作者:古茗前端团队
链接:https://juejin.cn/post/7297529039312158730
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。