您现在的位置是:网站首页> Android

蓝牙连接 ESC/POS 热敏打印机打印(蓝牙连接篇)

  • Android
  • 2022-02-24
  • 1783人已阅读
摘要

公司的一个手机端的 CRM 项目最近要增加小票打印的功能,就是我们点外卖的时候经常会见到的那种小票。这里主要涉及到两大块的知识:


蓝牙连接及数据传输

ESC/POS 打印指令

蓝牙连接不用说了,太常见了,这篇主要介绍这部分的内容。但ESC/POS 打印指令是个什么鬼?简单说,我们常见的热敏小票打印机都支持这样一种指令,只要按照指令的格式向打印机发送指令,哪怕是不同型号品牌的打印机也会执行相同的动作。比如打印一行文本,换行,加粗等都有对应的指令,这部分内容放在下一篇介绍。


本篇主要基于官方文档,相比官方文档,省去了大段的说明,更加便于快速上手。

demo及打印指令讲解请看下篇


1. 蓝牙权限

想要使用蓝牙功能,首先要在 AndroidManifest 配置文件中声明蓝牙权限:


<manifest> 

  <uses-permission android:name="android.permission.BLUETOOTH" />

  <uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />

  ...

</manifest>

BLUETOOTH 权限只允许建立蓝牙连接以及传输数据,但是如果要进行蓝牙设备发现等操作的话,还需要申请 BLUETOOTH_ADMIN 权限。


2. 初始配置

这里主要用到一个类 BluetoothAdapter。用法很简单,直接看代码:


BluetoothAdapter mBluetoothAdapter = BluetoothAdapter.getDefaultAdapter();

if (mBluetoothAdapter == null) {

    // Device does not support Bluetooth

}

单例模式,全局只有一个实例,只要为 null,就代表设备不支持蓝牙,那么需要有相应的处理。

如果设备支持蓝牙,那么接着检查蓝牙是否打开:


if (!mBluetoothAdapter.isEnabled()) {

    Intent intent = new Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE);

    startActivityForResult(intent, REQUEST_ENABLE_BT);

}

如果蓝牙未打开,那么执行 startActivityForResult() 后,会弹出一个对话框询问是否要打开蓝牙,点击`是`之后就会自动打开蓝牙。成功打开蓝牙后就会回调到 onActivityResult()。


除了主动的打开蓝牙,还可以监听 BluetoothAdapter.ACTION_STATE_CHANGED

广播,包含EXTRA_STATE和EXTRA_PREVIOUS_STATE两个 extra 字段,可能的取值包括 STATE_TURNING_ON, STATE_ON, STATE_TURNING_OFF, and STATE_OFF。含义很清楚了,不解释。


3. 发现设备

初始化完成之后,蓝牙打开了,接下来就是扫描附近的设备,只需要一句话:


mBluetoothAdapter.startDiscovery();

不过这样只是开始执行设备发现,这肯定是一个异步的过程,我们需要注册一个广播,监听发现设备的广播,直接上代码:


private final BroadcastReceiver mReceiver = new BroadcastReceiver() {

    public void onReceive(Context context, Intent intent) {

        String action = intent.getAction();

        

        // 当有设备被发现的时候会收到 action == BluetoothDevice.ACTION_FOUND 的广播

        if (BluetoothDevice.ACTION_FOUND.equals(action)) {


            //广播的 intent 里包含了一个 BluetoothDevice 对象

            BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE);


            //假设我们用一个 ListView 展示发现的设备,那么每收到一个广播,就添加一个设备到 adapter 里

            mArrayAdapter.add(device.getName() + "\n" + device.getAddress());

        }

    }

};

// 注册广播监听

IntentFilter filter = new IntentFilter(BluetoothDevice.ACTION_FOUND);

registerReceiver(mReceiver, filter); // Don't forget to unregister during onDestroy

注释已经写的很清楚了,除了 BluetoothDevice.EXTRA_DEVICE 之外,还有一个 extra 字段 BluetoothDevice.EXTRA_CLASS, 可以得到一个 BluetoothClass 对象,主要用来保存设备的一些额外的描述信息,比如可以知道这是否是一个音频设备。


关于设备发现,有两点需要注意:


startDiscovery() 只能扫描到那些状态被设为 可发现 的设备。安卓设备默认是不可发现的,要改变设备为可发现的状态,需要如下操作:

Intent intent = new Intent(BluetoothAdapter.ACTION_REQUEST_DISCOVERABLE);

//设置可被发现的时间,300s

intent.putExtra(BluetoothAdapter.EXTRA_DISCOVERABLE_DURATION, 300);

startActivity(intent);

执行之后会弹出对话窗询问是否允许设备被设为可发现的状态,点击`是`之后设备即被设为可发现的状态。


startDiscovery()是一个十分耗费资源的操作,所以需要及时的调用cancelDiscovery()来释放资源。比如在进行设备连接之前,一定要先调用cancelDiscovery()

4. 设备配对与连接

4.1 配对

当与一个设备第一次进行连接操作的时候,屏幕会弹出提示框询问是否允许配对,只有配对成功之后,才能建立连接。

系统会保存所有的曾经成功配对过的设备信息。所以在执行startDiscovery()之前,可以先尝试查找已配对设备,因为这是一个本地信息读取的过程,所以比startDiscovery()要快得多,也避免占用过多资源。如果设备在蓝牙信号的覆盖范围内,就可以直接发起连接了。


查找配对设备的代码如下:


Set<BluetoothDevice> pairedDevices = mBluetoothAdapter.getBondedDevices();

if (pairedDevices.size() > 0) {

    for (BluetoothDevice device : pairedDevices) {

        mArrayAdapter.add(device.getName() + "\n" + device.getAddress());

    }

}

代码很简单,不解释了,就是调用BluetoothAdapter.getBondedDevices()得到一个 Set<BluetoothDevice> 并遍历取得已配对的设备信息。


4.2 连接

蓝牙设备的连接和网络连接的模型十分相似,都是Client-Server 模式,都通过一个 socket 来进行数据传输。那么作为一个 Android 设备,就存在三种情况:


只作为 Client 端发起连接

只作为 Server 端等待别人发起建立连接的请求

同时作为 Client 和 Server

因为是为了下一篇介绍连接热敏打印机打印做铺垫,所以这里先讲 Android 设备作为 Client 建立连接的情况。因为打印机是不可能主动跟 Android 设备建立连接的,所以打印机必然是作为 Server 被连接。


4.2.1 作为 Client 连接

首先需要获取一个 BluetoothDevice 对象。获取的方法前面其实已经介绍过了,可以通过调用 startDiscovery()并监听广播获得,也可以通过查询已配对设备获得。

通过 BluetoothDevice.createRfcommSocketToServiceRecord(UUID) 得到 BluetoothSocket 对象

通过BluetoothSocket.connect()建立连接

异常处理以及连接关闭

废话不多说,上代码:


private class ConnectThread extends Thread {

    private final BluetoothSocket mmSocket;

    private final BluetoothDevice mmDevice;

 

    public ConnectThread(BluetoothDevice device) {


        BluetoothSocket tmp = null;

        mmDevice = device;

        try {

            // 通过 BluetoothDevice 获得 BluetoothSocket 对象

            tmp = device.createRfcommSocketToServiceRecord(MY_UUID);

        } catch (IOException e) { }

        mmSocket = tmp;

    }

     

    @Override

    public void run() {

        // 建立连接前记得取消设备发现

        mBluetoothAdapter.cancelDiscovery();

        try {

            // 耗时操作,所以必须在主线程之外进行

            mmSocket.connect();

        } catch (IOException connectException) {

            //处理连接建立失败的异常

            try {

                mmSocket.close();

            } catch (IOException closeException) { }

            return;

        }

        doSomething(mmSocket);

    }

 

    //关闭一个正在进行的连接

    public void cancel() {

        try {

            mmSocket.close();

        } catch (IOException e) { }

    }

}

device.createRfcommSocketToServiceRecord(MY_UUID) 这里需要传入一个 UUID,这个UUID 需要格外注意一下。简单的理解,它是一串约定格式的字符串,用来唯一的标识一种蓝牙服务。


Client 发起连接时传入的 UUID 必须要和 Server 端设置的一样!否则就会报错!


如果是连接热敏打印机这种情况,不知道 Server 端设置的 UUID 是什么怎么办?

不用担心,因为一些常见的蓝牙服务协议已经有约定的 UUID。比如我们连接热敏打印机是基于 SPP 串口通信协议,其对应的 UUID 是 "00001101-0000-1000-8000-00805F9B34FB",所以实际的调用是这样:


device.createRfcommSocketToServiceRecord(UUID.fromString("00001101-0000-1000-8000-00805F9B34FB"))

其他常见的蓝牙服务的UUID大家可以自行搜索。如果只是用于自己的应用之间的通信的话,那么理论上可以随便定义一个 UUID,只要 server 和 client 两边使用的 UUID 一致即可。更多关于 UUID 的介绍可以参考这里


4.2.2 作为 Server 连接

通过BluetoothAdapter.listenUsingRfcommWithServiceRecord(String, UUID)获取一个 BluetoothServerSocket 对象。这里传入的第一个参数用来设置服务的名称,当其他设备扫描的时候就会显示这个名称。UUID 前面已经介绍过了。

调用BluetoothServerSocket.accept()开始监听连接请求。这是一个阻塞操作,所以当然也要放在主线程之外进行。当该操作成功执行,即有连接建立的时候,会返回一个BluetoothSocket 对象。

调用 BluetoothServerSocket.close() 会关闭监听连接的服务,但是当前已经建立的链接并不会受影响。

还是看代码吧:


private class AcceptThread extends Thread {


    private final BluetoothServerSocket mmServerSocket;

 

    public AcceptThread() {


        BluetoothServerSocket tmp = null;

        try {

            // client 必须使用一样的 UUID !!!

            tmp = mBluetoothAdapter.listenUsingRfcommWithServiceRecord(NAME, MY_UUID);

        } catch (IOException e) { }

        mmServerSocket = tmp;

    }


    @Override

    public void run() {

        BluetoothSocket socket = null;

        //阻塞操作

        while (true) {

            try {

                socket = mmServerSocket.accept();

            } catch (IOException e) {

                break;

            }

            //直到有有连接建立,才跳出死循环

            if (socket != null) {

                //要在新开的线程执行,因为连接建立后,当前线程可能会关闭

                doSomething(socket);

                mmServerSocket.close();

                break;

            }

        }

    }

 

    public void cancel() {

        try {

            mmServerSocket.close();

        } catch (IOException e) { }

    }

}

5. 数据传输

终于经过了前面的4步,万事俱备只欠东风。而最后这一部分其实是最简单的,因为就只是简单的利用 InputStream 和OutputStream进行数据的收发。

示例代码:



private class ConnectedThread extends Thread {

    private final BluetoothSocket mmSocket;

    private final InputStream mmInStream;

    private final OutputStream mmOutStream;

 

    public ConnectedThread(BluetoothSocket socket) {

        mmSocket = socket;

        InputStream tmpIn = null;

        OutputStream tmpOut = null;

        //通过 socket 得到 InputStream 和 OutputStream

        try {

            tmpIn = socket.getInputStream();

            tmpOut = socket.getOutputStream();

        } catch (IOException e) { }

 

        mmInStream = tmpIn;

        mmOutStream = tmpOut;

    }

 

    public void run() {

        byte[] buffer = new byte[1024];  // buffer store for the stream

        int bytes; // bytes returned from read()

 

        //不断的从 InputStream 取数据

        while (true) {

            try {

                bytes = mmInStream.read(buffer);

                mHandler.obtainMessage(MESSAGE_READ, bytes, -1, buffer)

                        .sendToTarget();

            } catch (IOException e) {

                break;

            }

        }

    }

 

    //向 Server 写入数据

    public void write(byte[] bytes) {

        try {

            mmOutStream.write(bytes);

        } catch (IOException e) { }

    }

 

    public void cancel() {

        try {

            mmSocket.close();

        } catch (IOException e) { }

    }

}


终于抽时间整了一个可以运行的demo出来,实现了以下功能:


检测蓝牙开启状态

显示已配对设备

连接打印机

打印测试,包括打印标题,打印两列三列文字,打印图片等

最终demo及打印的小票示例:


1.png

2.png

========================以下是原文=======================

1. 构造输出流

首先要明确一点,就是蓝牙连接打印机这种场景下,手机是 Client 端,打印机是 Server 端。


在上一篇的最后,我们从 BluetoothSocket 得到了一个OutputStream。这里我们做一层包装,得到一个OutputStreamWriter 对象:


OutputStreamWriter writer = new OutputStreamWriter(outputStream, "GBK");

这样做主要是为了后面可以直接输出字符串,不然只能输出 int 或 byte 数据;


2. 常用打印指令

手机通过蓝牙向打印机发送的都是纯字节流,那么打印机如何知道该打印的是一个文本,还是条形码,还是图片数据呢?这里就要介绍 ESC/POS 打印控制命令。


初始化打印机 :

1.png

初始化打印机指令

在每次打印开始之前要调用该指令对打印机进行初始化。向打印机发送这条指令对应的代码就是:


  protected void initPrinter() throws IOException {  

        writer.write(0x1B);  

        writer.write(0x40);  

        writer.flush();  

  }

打印文本:

没有对应指令,直接输出

protected void printText(String text) throws IOException {  

        writer.write(text);

        writer.flush();

    } 

设置文本对齐方式:

1.png

文本对齐方式指令

对应的发送指令的代码:


    /* 设置文本对齐方式

     * @param align 打印位置  0:居左(默认) 1:居中 2:居右 

     * @throws IOException 

     */  

    protected void setAlignPosition(int align) throws IOException {  

        writer.write(0x1B);  

        writer.write(0x61);  

        writer.write(align);  

        writer.flush();  

    }

与初始化指令不同的是,这条指令带有一个参数n。


换行和制表符:

直接输出对应的字符:

    protected void nextLine() throws IOException {  

        writer.write("\n");  

        writer.flush();  

    }


    protected void printTab(int length) throws IOException {  

        for (int i = 0; i < length; i++) {  

            writer.write("\t");  

        }  

        writer.flush();  

    }  

这两个指令在打印订单详情的时候使用最多。尤其是制表符,可以让每一列的文字对齐。


设置行间距:

1.png

设置行间距指令


n表示行间距为n个像素点,最大值256


protected void setLineGap(int gap) throws IOException {  

        writer.write(0x1B);  

        writer.write(0x33);  

        writer.write(gap);  

        writer.flush();  

}

这个指令在后面打印图片的时候会用到。


3. 打印图片

很多小票上面都会附上一个二维码,用户扫描之后,可以获得更多的信息。因为热敏打印机只能打印黑白两色,所以首先把图片转成纯黑白的,再调用图片打印指令进行打印。


3.1 打印图片指令

1.png

打印图片指令

这个指令的参数很多,一个一个来说:


m:取值十进制 0、1、32、33。设置打印精度,0、1对应每行8个点,32、33对应每行24个点,对应最高的打印精度(其实这里也没太搞清楚取值0、1或者取值32、33的区别,只要记住取值33,对应每行24个点,后面还有用)

n1, n2 : 表示图片的宽度,为什么有两个?其实只是分成了高位和低位两部分,因为每部分只有8bit,最大表示256。所以 n1 = 图片宽度 % 256,n2 = 图片宽度 / 256。假设图片宽300,那么n1=1,n2=44

d1 d2 ... dk 这部分就是转换成字节流的图像数据了

3.2 图片分辨率调整

如果分辨率过大,超过了打印机可打印的最大宽度,那么超出的部分将无法打印。我试验的这台最大宽度是 384 个像素点,超过这个宽度的数据无法被打印出来。所以在开始打印之前,我们需要调整图片的分辨率。代码如下:


    /**

     * 对图片进行压缩(去除透明度)

     *

     * @param bitmapOrg

     */

    public static Bitmap compressPic(Bitmap bitmap) {

        // 获取这个图片的宽和高

        int width = bitmap.getWidth();

        int height = bitmap.getHeight();

        // 指定调整后的宽度和高度

        int newWidth = 240;

        int newHeight = 240;

        Bitmap targetBmp = Bitmap.createBitmap(newWidth, newHeight, Bitmap.Config.ARGB_8888);

        Canvas targetCanvas = new Canvas(targetBmp);

        targetCanvas.drawColor(0xffffffff);

        targetCanvas.drawBitmap(bitmap, new Rect(0, 0, width, height), new Rect(0, 0, newWidth, newHeight), null);

        return targetBmp;

    }

3.2 图片黑白化处理

因为能够打印的图像只有黑白两色,所以需要先做黑白化的处理。这一部分其实又细分为彩色图片->灰度图片,灰度图片->黑白图片两步。直接上代码:


    /**

     * 灰度图片黑白化,黑色是1,白色是0

     *

     * @param x   横坐标

     * @param y   纵坐标

     * @param bit 位图

     * @return

     */

    public static byte px2Byte(int x, int y, Bitmap bit) {

        if (x < bit.getWidth() && y < bit.getHeight()) {

            byte b;

            int pixel = bit.getPixel(x, y);

            int red = (pixel & 0x00ff0000) >> 16; // 取高两位

            int green = (pixel & 0x0000ff00) >> 8; // 取中两位

            int blue = pixel & 0x000000ff; // 取低两位

            int gray = RGB2Gray(red, green, blue);

            if (gray < 128) {

                b = 1;

            } else {

                b = 0;

            }

            return b;

        }

        return 0;

    }


    /**

     * 图片灰度的转化

     */

    private static int RGB2Gray(int r, int g, int b) {

        int gray = (int) (0.29900 * r + 0.58700 * g + 0.11400 * b);  //灰度转化公式

        return gray;

    }

其中的灰度化转换公式是一个广为流传的公式,具体原理不明。我们直接看灰度转化为黑白的函数 px2Byte(int x, int y, Bitmap bit)。对于一个 Bitmap 中的任意一个坐标点,取出其 RGB 三色信息后做灰度化处理,然后对于灰度小于128的,用黑色表示,灰度大于128的,用白色表示。


3.3 逐行打印图片

其实打印图片和打印文本是一样的,也是一行一行的打印。直接上代码吧,注释已经尽量详细了。


    /*************************************************************************

     * 假设一个240*240的图片,分辨率设为24, 共分10行打印

     * 每一行,是一个 240*24 的点阵, 每一列有24个点,存储在3个byte里面。

     * 每个byte存储8个像素点信息。因为只有黑白两色,所以对应为1的位是黑色,对应为0的位是白色

     **************************************************************************/

    /**

     * 把一张Bitmap图片转化为打印机可以打印的字节流

     *

     * @param bmp

     * @return

     */

    public static byte[] draw2PxPoint(Bitmap bmp) {

        //用来存储转换后的 bitmap 数据。为什么要再加1000,这是为了应对当图片高度无法      

        //整除24时的情况。比如bitmap 分辨率为 240 * 250,占用 7500 byte,

        //但是实际上要存储11行数据,每一行需要 24 * 240 / 8 =720byte 的空间。再加上一些指令存储的开销,

        //所以多申请 1000byte 的空间是稳妥的,不然运行时会抛出数组访问越界的异常。

        int size = bmp.getWidth() * bmp.getHeight() / 8 + 1000;

        byte[] data = new byte[size];

        int k = 0;

        //设置行距为0的指令

        data[k++] = 0x1B;

        data[k++] = 0x33;

        data[k++] = 0x00;

        // 逐行打印

        for (int j = 0; j < bmp.getHeight() / 24f; j++) {

            //打印图片的指令

            data[k++] = 0x1B;

            data[k++] = 0x2A;

            data[k++] = 33; 

            data[k++] = (byte) (bmp.getWidth() % 256); //nL

            data[k++] = (byte) (bmp.getWidth() / 256); //nH

            //对于每一行,逐列打印

            for (int i = 0; i < bmp.getWidth(); i++) {

                //每一列24个像素点,分为3个字节存储

                for (int m = 0; m < 3; m++) {

                    //每个字节表示8个像素点,0表示白色,1表示黑色

                    for (int n = 0; n < 8; n++) {

                        byte b = px2Byte(i, j * 24 + m * 8 + n, bmp);

                        data[k] += data[k] + b;

                    }

                    k++;

                }

            }

            data[k++] = 10;//换行

        }

        return data;

    }

4. 总结

用两篇介绍了一个比较冷门的应用,纯粹是因为自己花了很多时间去搞懂原理,所以希望记录下来。尤其是图片打印部分,废了好多纸啊哈哈哈,一个字节操作错误,打印出来就是一堆乱码。感觉和 java 的 .class 文件很像,每一个指令占用多少位,每一位表示什么都是严格规定好的,不能超出也不能缺少。

最后希望能帮到需要的人吧,感觉网上这部分资料还是比较少的。




Top