打印机/电子秤 对接

由于原小赢家项目使用 lodop 打印,部分打印机不支持。
现采用 nodejs 调用打印机驱动的方式,以兼容多种厂商打印机(主要是小票打印机)。
小票打印机主要是 USB 类型热敏打印机,xprinter 机型。
项目中遇到挺多不可避免的问题,下面是问题与解决方法记录。

调用驱动

由于项目是 nw.js,且依赖包比较多,只能采用 node 调用 printer c模块驱动打印。
问题也在这,编译 c
模块比较繁琐,步步踩坑,安装 Visual Studio 总是丢配置,最终采用虚拟机搭建出一套稳定的编译环境。以下是编译原生模块步骤(仅针对 LTS 版本)。

  1. 你需要确认你的 nw.js 版本及 node 版本(对于编译原生模块很重要
    1. 正常情况官方下载时,你就可以知道相关版本,二开项目需要确认好。
    2. 小赢家项目是 nw.js 版本是 0.35.5, node 版本是 11.6.0
  2. 安装 c++ build 环境
    1. node-printer 的话需要 node-gyp 进行 rebuild,才能在 NW.js 里面使用,不然无法正常使用;
    2. 如果电脑有安装过 Visual Studio 那就可以跳过这一步,如果没有那就需要安装;
    3. 可以使用 windows-build-tools 来安装这个环境:npm install --global windows-build-tools
    4. 安装 Visual C++ Build Tools 以及 Python2.7 环境时,这个时候只要去到 C:\Users\用户.windows-build-tools 下双击打开 vs_BuildTools.exe,进入之后勾选 Node.js MSBuild 、Visual C++ Build Tools 支持,然后下载安装即可
    5. 如果电脑没有 .NET Framework 的环境,顺便下载安装一下:https://dotnet.microsoft.com/download/visual-studio-sdks
    6. 如果不使用 windows-build-tools 安装,可以自行去 Visual C++ Build Tools 以及 Python 官网自行下载安装
  3. 安装node-gyp
    1. npm install --global node-gyp
    2. 安装完成把 npm-path/node_modules/node-gyp/src/win_delay_load_hook.cc 替换成 https://github.com/nwjs/nw.js/blob/nw35/tools/win_delay_load_hook.cc
      1. hook 时,需要注意你的 nw 版本,windows 需要 hook,linux 和 mac 不需要。
  4. 重新构建 node-printer
    1. 进入 project_path/node_modules/printer 目录在里面执行重新构建的命令:
      1. 正常情况执行 node-gyp rebuild 就可以编译成功
      2. 继续说了正常情况,那自然成功不了,这时你可以安装 nw-gyp。
        1. nw-gyp is a hack on node-gyp to support NW.js specific headers and libraries.
      3. 继续运行编译命令 **nw-gyp rebuild --target 0.35.5 --arch=x64 --python=your dir path **
  5. 完成了以上所有步骤之后,就可以在 NW.js 里面快乐的使用 node-printer 操作打印机了
    1. 如果还不能正常调用,请重新配置环境及对原生模块进行构建,没别的办法,特别坑!!!
    2. 还有个玄学问题,由于 node-printer 模块存在兼容性问题,你的 nodejs 版本不能太高,即使使用 nw.js 支持的 11.6.0 也有问题,本地编译使用改的 node 版本是 **10.11.0 **。

指令集处理

安装指定厂商的打印机驱动之后,就可以使用 printer 模块进行驱动了。
不过这时候还没完,因为热敏打印机是基于 ESC/POS指令集 的,我们还需要封装一套靠谱的模板,简化操作。
需要支持简单配置,就可以完成二维码、文字、排版、设置字体、需要支持流数据传输等操作。
所幸 github 有作者开源过一套模板,这里直接拿来封装一下就可以使用。command 指令集
案例如下:

const { Printer, Command } = require("../index");

let printer = new Printer();
let cmd = new Command();

!async function () {
    cmd.textCenter("条码打印测试(EAN-13)")
    cmd.newLine(2)
    .barcode("123456789012")
    .newLine(8)
    .cut();
    await printer.write(cmd.export());
    printer.destroy();
}();
javascript

动态配置

当你觉得快要结束之后,事情往往没有这么简单,由于目前使用的 lodop 插件打印已经有可 div 的后台模板配置(这里可以调整字体大小、宽度、间距等)。我们需要使用现有的功能,使用 Command 模块进行动态适配。
根据和运营确认,目前只适配后端模块的增减,固定布局和字号,以打印退款小票为例:

function generateRefundData({ width, data }) {
  const cmd = new Command({
    printeWidth: width
  })

  cmd.fontSize(1).textCenter(data.store_name, ' ').newLine(2)

  cmd.fontSize()

  cmd.text(`订单编号: ${data.order_no}`).newLine()
  cmd.text(`订单类型: 退款订单`).newLine()
  cmd.text(`退款时间: ${data.refund_time}`).newLine()

  cmd.newLine()

  let goods_info = data.goods_info
  if (Array.isArray(goods_info) && goods_info.length) {
    cmd
      .fontSize()
      .textRow(['名称', '单价', '数量', '小计'])
      .text('*'.repeat(30))
      .newLine()

    goods_info.forEach(item => {
      cmd.text(item.goods_name).newLine()
      cmd.textRow([
        '',
        item.code_price_state == 1 ? '' : item.price + '',
        item.num + (item.company || ''),
        item.before_all_money + ''
      ])
    })

    cmd.text('*'.repeat(30)).newLine()
  }

  cmd.text(`实退金额: ${data.all_money}元`).newLine()

  if (data.pay_coupon_fee)
    cmd.text(`代金券: ${data.pay_coupon_fee}元`).newLine()

  let refund_list = data.refund_list
  if (Array.isArray(refund_list) && refund_list.length) {
    refund_list.forEach(item => {
      cmd.text(`退款方式: ${item.name}`).newLine()
    })
  }

  let vip_data = data.vip_data
  if (vip_data) {
    cmd.text(`账户余额: ${vip_data.balance}`).newLine()
    cmd.text(`账户积分: ${vip_data.integral}`).newLine()
  }

  cmd.text(`收银员: ${data.job_num}`).newLine()

  cmd.newLine(3)

  return cmd.export()
}
javascript
const _print = (printerName, type, data, success, error) => {
  try {
    printer.printDirect({
      printer: printerName,
      type, // raw text ...
      data,
      success: function (jobID) {
        console.log('sent to printer with ID: ' + jobID)
        success && success(jobID)
      },
      error: error || function () {}
    })
  } catch (error) {
    console.log(error)
  }
}

const printRefund = options => {
  const {
    printerName = '',
    type = 'raw',
    data = {},
    width = '58mm',
    success,
    error
  } = options

  const bufferData = generateRefundData({
    width,
    data
  })

  _print(printerName, type, bufferData, success, error)
}
javascript

秤相关

电子秤可以分为条码秤和计价秤。条码秤主要用于扫描商品上的条形码,并根据条码上的信息获取商品的重量、价格和其他相关信息。计价秤主要用于称量商品的重量,并根据称量的重量计算商品的价格。
我们需要支持市场普遍使用的 :

  • 计价秤:大华(12 公斤)、顶尖(OS2X、PSX);
  • 条码秤:大华(TM-A)、顶尖(LS2w、LS2X 系列);

计价秤主要是解决称重问题,即得到商品的重量,根据商品价格自主计算商品的数量,总金额。
条码秤需要解决两个问题:

  • 数据传秤:通常需要把商品批量传递给电子秤(传秤协议),然后由电子秤打印条码;
  • 条码识别:需要识别电子秤打印的条码 ,通常分为 18 位生鲜码和 13 位金额码或 13 位重量码。
    • 18位(2F+5W+5E+5N+C) 生鲜码
    • 13位(2F+5W+5E+C) 金额码
    • 13位(2F+5W+5N+C) 重量码

明确问题,就可以分步去解决,遇到问题解决问题即可。

计价秤(称重)

目前项目中已经支持大华称重,使用 serialport 模块,主要是通过它访问串口,连接建立起来之后,可以得到二进制数据,然后再对这些二进制数据进行解析,具体解析代码这里就不贴出,只贴关键代码。

 this.serialPort = new SerialPort(
      port,
      {
        baudRate: parseInt(scaleBaudRate), // 波特率
        dataBits: parseInt(scaleDataBits), // 数据位
        parity: parity, // 校验位
        stopBits: parseInt(scaleStopBits), // 停止位
        flowControl: false,
        autoOpen: false
      },
      false
    )

    this.serialPort.open(error => {
      if (!error) {
        console.log('打开端口成功,正在监听数据中')
      }
    })

    var datas = ''
    this.serialPort.on('data', data => {
      console.log(`[log] serial: chunk ${data}`)
    })
javascript

你需要根据秤厂家提供的文档对数据流进行解析,例如下面是大华计价秤的通信协议:

image.png

初次之外,我们还需要支持顶尖称重,不过顶尖厂家并不暴露传输端口,所以我们没办法使用 serialport 模块进行连接,和厂家沟通之后,由厂家提供可供 nodejs 调用的 dll 模块。调用 dll 模块之后,得到二进制数据,根据厂家文档进行解析。
nodejs 要想调用 dll,还需要借助原生模块 node-ffi,没错,又是它,具体编译过程就不多说了,和编译 node-printer 基本一致,可以参考 NodeJS和NW通过ffi调用dll/so动态库 这篇文章。
这里只贴出封装过的调用代码:

class Aclas {
	constructor() {
		this.isOpen = false
		this.instance = null
		this.timer = null
	}

	create() {
		const dll_path = path.resolve('public/utils/scales', `dll/SensorDll.dll`)
		this.instance = ffi.Library(dll_path, {
			__Open: ['bool', ['string', 'int']],
			__Close: ['bool', []],
			__GetWeight: ['string', []]
		})
		return this
	}

	open(comName, rate) {
		const isOpen = this.instance.__Open(comName, rate)
		this.isOpen = isOpen
		return isOpen
	}

	getWeight(callback) {
		this._clear()

		if (this.isOpen) {
			this.timer = setInterval(() => {
				callback(this.instance.__GetWeight())
			}, 500);
		}
	}

	close() {
		this._clear()

		if (this.isOpen) {
			this.instance.__Close()
			this.isOpen = false
		}
	}

	_clear() {
		if (this.timer) {
			clearInterval(this.timer)
			this.timer = null
		}
	}
}

class FFIFactory {
	create(mode) {
		switch (mode) {
			case 'aclas':
				return new Aclas().create()
			default:
				throw new Error('no instance')
		}
	}

	constructor(mode) {
		this.instance = this.create(mode)
	}

	open(comName, rate) {
		if (!comName) throw new Error('comName parameter does not exist')
		if (!rate) throw new Error('comName parameter does not exist')
		return this.instance.open(comName, rate)
	}

	getWeight(callback) {
		if (!callback || typeof callback !== 'function')
			throw new Error('function parameter does not exist')
		return this.instance.getWeight(callback)
	}

	close() {
		this.instance.close()
	}
}
javascript

如果还需要对接其他计价秤,可以灵活拓展,只要厂家提供 dll 模块就可以。

条码秤(传秤)

目前项目中同样支持大华传秤,采用 node net 模块,使用 TCP 协议进行链接。它和 websocket 能力类似。

net.Socket是一种基于TCP的原始套接字通信方式,需要自行定义和解析通信数据格式。WebSocket是一种基于HTTP协议的通信协议扩展,提供了双向实时通信的能力,并有一套标准的消息格式和处理方式。它们适用于不同的网络通信场景。

你只需要按照传承协议组装好参数,传递给电子秤即可。

var port = 4001
var client = new net.Socket()
client.connect(port, req.body.ip, () => {
  console.log('连接到:' + req.body.ip)
  var isSuccess = client.write(req.body.str)
  if (!isSuccess) {
    client.once('drain', () => {
      client.write(req.body.str)
    })
  }
})
javascript

顶尖条码秤,我们还是需要借助调用 dll 的方式来解决(这里需要通过dll传递文件)。

image 1.png

image 2.png

主要思路就是构造本地传输文件,将文件地址传递给 dll,使用完删除即可。具体代码如下:

// 传输顶尖数据
function transferDJData(options, callback) {
  const { addr, file } = options

  const deviceInfoResult = refStruct({
    ProtocolType: ref.types.int32,
    Addr: ref.types.int32,
    Port: ref.types.int32,
    name: ref.types.char,
    ID: ref.types.int32,
    Version: ref.types.int32,
    Country: ref.types.byte,
    DepartmentID: ref.types.byte,
    KeyType: ref.types.byte,
    PrinterDot: ref.types.int64,
    PrnStartDate: ref.types.long,
    LabelPage: ref.types.int32,
    PrinterNo: ref.types.int32,
    PLUStorage: ref.types.short,
    HotKeyCount: ref.types.short,
    NutritionStorage: ref.types.short,
    DiscountStorage: ref.types.short,
    Note1Storage: ref.types.short,
    Note2Storage: ref.types.short,
    Note3Storage: ref.types.short,
    Note4Storage: ref.types.short,
    stroge: ref.types.byte
  })
  const dll = ffi.Library('./public/utils/scales/dll/AclasSDK.dll', {
    // 初始化动态链接库(pointer指向任何类型参数,此处传nil或null)
    AclasSDK_Initialize: ['bool', ['Pointer']],
    // 获取设备协议类型
    AclasSDK_GetDeviceType: ['int', ['UInt32', 'UInt32', 'UInt32']],
    // 获取设备信息
    AclasSDK_GetDeviceInfo: [deviceInfoResult, ['UInt32', 'UInt32', 'UInt32']],
    // 无回调函数执行任务
    AclasSDK_Sync_ExecTaskA_PB: [
      'int',
      ['string', 'UInt32', 'UInt32', 'UInt32', 'UInt32', 'string']
    ],
    // 释放动态链接库
    AclasSDK_Finalize: ['void', []]
  })
  // 定义秤ip,需要转换秤数值
  const addrArr = addr.split('.').map(n => parseInt(n))
  const numberIp =
    addrArr[0] * 256 * 256 * 256 +
    addrArr[1] * 256 * 256 +
    addrArr[2] * 256 +
    addrArr[3]
  // 初始化dll链接库,返回bool值
  const initSuccess = dll.AclasSDK_Initialize(null)

  if (initSuccess) {
    // 链接库初始化成功后获取设备协议类型,返回0代表连接秤类型不存在
    const deviceType = dll.AclasSDK_GetDeviceType(numberIp, 0, 0)
    if (deviceType !== 0) {
      // 获取秤类型后获取秤信息
      const deviceInfo = dll.AclasSDK_GetDeviceInfo(numberIp, 0, deviceType)
      if (Object.keys(deviceInfo).length > 0) {
        // addr 定义设备ip供AclasSDK_Sync_ExecTaskA_PB函数使用
        // file 定义执行任务的目标文件绝对路径
        const startWork = dll.AclasSDK_Sync_ExecTaskA_PB(
          addr, // ip地址
          deviceInfo.Port, // 端口号
          deviceInfo.ProtocolType, // 协议类型
          0, // 操作类型 0代表下载文件
          0, // 数据类型 0代表PLU文件
          file // 文件地址(绝对路径)
        )

        console.log(startWork)
        callback && callback(startWork)
      }
    }
  }
  // 释放动态链接库
  dll.AclasSDK_Finalize()
}

// 顶尖传秤接口
app.post('/DJChuancheng', function (req, res, next) {
  const { ip, isChangePage, data } = req.body

  try {
    const file = generateDJData(isChangePage, data)

    transferDJData(
      {
        addr: ip,
        file
      },
      workStatus => {
        fs.unlinkSync(file)
        res.json({ status: 1, data: workStatus, res: JSON.parse(data) })
      }
    )
  } catch (error) {
    res.json({ status: 0 })
  }
})
javascript

至此传秤问题也得到解决。

条码识别

需要识别条码秤打印出来的条码,目前主要识别以下三种类型:
● 18位(2F+5W+5E+5N+C) 生鲜码
● 13位(2F+5W+5E+C) 金额码
● 13位(2F+5W+5N+C) 重量码

条码识别其实没什么好说的,就是根据条码的长度分割,进行识别。
对于 18 位生鲜码需要和商品货号区分开,不要混淆。

is13ScaleBarcode(barcode) {
  if (/^\d{13}$/.test(barcode)) {
    if (/^[0-9]+$/.test(barcode) && barcode.startsWith('69')) {
      console.log('解析处理:13 位标准商品 69 码', barcode)
      return false
    }
    console.log('解析处理:13 位称重码', barcode)
    return true
  }
  return false
}

if (searchcode.length == 18) {
  // 货号
  search_goods_number = searchcode.substr(2, 5)
  // 重量
  search_weight = searchcode.substr(12, 5)
  search_weight = parseFloat(search_weight / 1000)
  // 价格
  search_price = toDecimal(searchcode.substr(7, 5) / 100)
} else if (this.is13ScaleBarcode(searchcode)) {
  switch (this.scaleBarcode) {
    case 'price':
      search_goods_number = searchcode.substr(2, 5)
      search_price = toDecimal(searchcode.substr(7, 5) / 100)
      break
    case 'weight':
      search_goods_number = searchcode.substr(2, 5)
      search_weight = parseFloat(searchcode.substr(7, 5) / 1000)
      break
  }
}
javascript

总结

  1. 多多少少走了很多弯路,还是要多看官方文档。
  2. 解决问题的过程是痛苦的,但是收获也很多。

参考文章

node-gyp

打印机

在NW.js里面使用node-printer
Use Native Node Modules
escpos 指令打印机驱动
node-printer

秤相关

NodeJS和NW通过ffi调用dll/so动态库
serialport
node-ffi