打印机/电子秤 对接
由于原小赢家项目使用 lodop 打印,部分打印机不支持。
现采用 nodejs 调用打印机驱动的方式,以兼容多种厂商打印机(主要是小票打印机)。
小票打印机主要是 USB 类型热敏打印机,xprinter 机型。
项目中遇到挺多不可避免的问题,下面是问题与解决方法记录。
调用驱动
由于项目是 nw.js,且依赖包比较多,只能采用 node 调用 printer c模块驱动打印。
问题也在这,编译 c 模块比较繁琐,步步踩坑,安装 Visual Studio 总是丢配置,最终采用虚拟机搭建出一套稳定的编译环境。以下是编译原生模块步骤(仅针对 LTS 版本)。
- 你需要确认你的 nw.js 版本及 node 版本(对于编译原生模块很重要)
- 正常情况官方下载时,你就可以知道相关版本,二开项目需要确认好。
- 小赢家项目是 nw.js 版本是 0.35.5, node 版本是 11.6.0。
- 安装 c++ build 环境
- node-printer 的话需要 node-gyp 进行 rebuild,才能在 NW.js 里面使用,不然无法正常使用;
- 如果电脑有安装过 Visual Studio 那就可以跳过这一步,如果没有那就需要安装;
- 可以使用 windows-build-tools 来安装这个环境:npm install --global windows-build-tools
- 安装 Visual C++ Build Tools 以及 Python2.7 环境时,这个时候只要去到 C:\Users\用户.windows-build-tools 下双击打开 vs_BuildTools.exe,进入之后勾选 Node.js MSBuild 、Visual C++ Build Tools 支持,然后下载安装即可
- 如果电脑没有 .NET Framework 的环境,顺便下载安装一下:https://dotnet.microsoft.com/download/visual-studio-sdks
- 如果不使用 windows-build-tools 安装,可以自行去 Visual C++ Build Tools 以及 Python 官网自行下载安装
- 安装node-gyp
- npm install --global node-gyp
- 安装完成把 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
- hook 时,需要注意你的 nw 版本,windows 需要 hook,linux 和 mac 不需要。
- 重新构建 node-printer
- 进入 project_path/node_modules/printer 目录在里面执行重新构建的命令:
- 正常情况执行 node-gyp rebuild 就可以编译成功
- 继续说了正常情况,那自然成功不了,这时你可以安装 nw-gyp。
- nw-gyp is a hack on node-gyp to support NW.js specific headers and libraries.
- 继续运行编译命令 **nw-gyp rebuild --target 0.35.5 --arch=x64 --python=your dir path **
- 进入 project_path/node_modules/printer 目录在里面执行重新构建的命令:
- 完成了以上所有步骤之后,就可以在 NW.js 里面快乐的使用 node-printer 操作打印机了
- 如果还不能正常调用,请重新配置环境及对原生模块进行构建,没别的办法,特别坑!!!
- 还有个玄学问题,由于 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
你需要根据秤厂家提供的文档对数据流进行解析,例如下面是大华计价秤的通信协议:
初次之外,我们还需要支持顶尖称重,不过顶尖厂家并不暴露传输端口,所以我们没办法使用 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传递文件)。
主要思路就是构造本地传输文件,将文件地址传递给 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
总结
- 多多少少走了很多弯路,还是要多看官方文档。
- 解决问题的过程是痛苦的,但是收获也很多。
参考文章
打印机
在NW.js里面使用node-printer
Use Native Node Modules
escpos 指令打印机驱动
node-printer