五、ESC/POS 指令模型
打印机本质上是一个顺序执行的硬件设备:
- 不解析 HTML
- 不理解 JSON
- 不具备页面布局能力
它唯一能够处理的,是一段按顺序输入的字节流(Byte Stream)。
因此,要驱动打印机完成打印任务,必须将业务数据转换为其所支持的打印控制语言。在便携式热敏打印机领域,这一标准就是 ESC/POS。
ESC/POS 概述
ESC/POS 是由 EPSON 定义的一套打印控制指令体系,现已成为热敏票据打印的事实标准。
其核心特征是:
- 以控制字符开头:
ESC(0x1B)GS(0x1D)
- 后跟一个或多个参数字节
- 构成一条完整指令
控制指令通常由以下几部分组成:

指令流结构
一条完整的 ESC/POS 指令流通常由以下部分组成:
- 初始化命令:复位打印机状态
- 格式控制命令:对齐、字体、加粗、行距等
- 内容数据:文本、条码、二维码、图片
- 结束控制命令:走纸、切纸
流式执行模型
打印机采用流式处理机制:
边接收 → 边解析 → 边执行
不存在"完整接收后再统一执行"的过程。
因此:
👉 指令发送顺序必须严格等于打印顺序
常用 ESC/POS 指令
下面是配送回单场景中最常用的一组指令,以 JavaScript 对象形式组织便于后续封装使用。
const ESC_POS_COMMANDS = {
// 初始化
INIT: [0x1b, 0x40],
// 换行
LF: [0x0a],
CR: [0x0d],
CRLF: [0x0d, 0x0a],
// 切纸(GS V m)
CUT_FULL: [0x1d, 0x56, 0x41, 0x00], // 全切(部分打印机支持)
CUT_PARTIAL: [0x1d, 0x56, 0x42, 0x00], // 半切(留一个连接点)
CUT: [0x1d, 0x56, 0x01], // 标准切纸指令
// 对齐(ESC a n)
ALIGN_LEFT: [0x1b, 0x61, 0x00],
ALIGN_CENTER: [0x1b, 0x61, 0x01],
ALIGN_RIGHT: [0x1b, 0x61, 0x02],
// 字体样式(ESC E n)
BOLD_ON: [0x1b, 0x45, 0x01],
BOLD_OFF: [0x1b, 0x45, 0x00],
// 字体大小(GS ! n)
FONT: [0x1d, 0x21],
FONT_NORMAL: [0x1d, 0x21, 0x00],
FONT_DOUBLE_HEIGHT: [0x1d, 0x21, 0x01],
FONT_DOUBLE_WIDTH: [0x1d, 0x21, 0x10],
FONT_DOUBLE: [0x1d, 0x21, 0x11],
// 行间距(ESC 3 n)
LINE_SPACING_DEFAULT: [0x1b, 0x32],
LINE_SPACING: [0x1b, 0x33] // 后跟一个字节表示间距
}
完整指令参考 :更多指令请查阅打印机厂商提供的 ESC/POS 编程手册。 传送门
文本编码转换
为什么会出现乱码?
在小程序环境中,
- JavaScript 字符串内部使用 UTF-16 编码
- BLE 发送通常使用 UTF-8
- 而大多数便携式热敏打印机只支持 GBK 或 GB2312 这类中文字符集。
如果直接将 UTF-8 编码的中文发送给打印机,就会出现经典的"乱码"问题。
因此,必须在发送前完成编码转换:
UTF-16(JS) → GBK(打印机)
小程序环境下的转换方案
小程序不支持 Node.js 的 Buffer 或标准 Web API TextEncoder(其编码参数 encoding 在部分环境中无效)。工程上推荐使用纯 JavaScript 编码库,通过"查表法"实现编码转换:
以下以 text-encoding-gbk 为例展示转换函数:
import { TextEncoder } from "text-encoding-gbk"
const testFn = () => {
const encoder = new TextEncoder("GB2312", { NONSTANDARD_allowLegacyEncoding: true })
const bytes = encoder.encode("你好")
console.log(bytes)
return bytes
}
打印任务的工程化封装
在实际项目中,如果直接拼接字节数组,会带来以下问题:
- 可读性差
- 维护成本高
- 易出错
因此建议封装打印任务。
PrintJob封装示例
import { TextEncoder } from "text-encoding-gbk"
const ESC_POS_COMMANDS = {
// 初始化
INIT: [0x1b, 0x40],
// 换行
LF: [0x0a],
CR: [0x0d],
CRLF: [0x0d, 0x0a],
// 切纸(GS V m)
CUT_FULL: [0x1d, 0x56, 0x41, 0x00], // 全切(部分打印机支持)
CUT_PARTIAL: [0x1d, 0x56, 0x42, 0x00], // 半切(留一个连接点)
CUT: [0x1d, 0x56, 0x01], // 标准切纸指令
// 对齐(ESC a n)
ALIGN_LEFT: [0x1b, 0x61, 0x00],
ALIGN_CENTER: [0x1b, 0x61, 0x01],
ALIGN_RIGHT: [0x1b, 0x61, 0x02],
// 字体样式(ESC E n)
BOLD_ON: [0x1b, 0x45, 0x01],
BOLD_OFF: [0x1b, 0x45, 0x00],
// 字体大小(GS ! n)
FONT: [0x1d, 0x21],
FONT_NORMAL: [0x1d, 0x21, 0x00],
FONT_DOUBLE_HEIGHT: [0x1d, 0x21, 0x01],
FONT_DOUBLE_WIDTH: [0x1d, 0x21, 0x10],
FONT_DOUBLE: [0x1d, 0x21, 0x11],
// 行间距(ESC 3 n)
LINE_SPACING_DEFAULT: [0x1b, 0x32],
LINE_SPACING: [0x1b, 0x33] // 后跟一个字节表示间距
}
// 如果你使用的是 CommonJS,请取消下行的注释并删除上面的 import
// const iconv = require('iconv-lite');
/**
* 默认的文本样式状态
*/
const DEFAULT_TEXT_OPTIONS = {
bold: false,
align: "left",
lineSpacing: 64,
size: 1
}
export class ESCPOSGenerator {
/**
* @param {string} [encoding='gb2312'] - 编码格式
* @param {number} [pageWidth=48] - 页面宽度(字符数)
*/
constructor(encoding = "gb2312", pageWidth = 48) {
this.commands = []
this.currentEncoding = encoding
this.pageWidth = pageWidth
this.encoder = new TextEncoder(encoding, { NONSTANDARD_allowLegacyEncoding: true })
// 当前状态
this.currentState = { ...DEFAULT_TEXT_OPTIONS }
}
/**
* 初始化打印机
* @returns {this}
*/
init() {
this.pushCommand(ESC_POS_COMMANDS.INIT)
return this
}
/**
* 添加文本
* @param {string} content - 文本内容
* @param {Object} [options={}] - 样式选项
* @returns {this}
*/
text(content, options = {}) {
const nextState = { ...this.currentState, ...options }
const prevState = { ...this.currentState }
// 对齐每次都加
this.align(nextState.align || "left")
// 只发送"变化的指令"
this.applyDiffStyle(nextState)
// 添加文本内容
const encoded = this.encoder.encode(content)
this.commands.push(...encoded)
if (Object.keys(options).length > 0) {
this.applyDiffStyle(prevState)
}
return this
}
/**
* 添加文本并换行
* @param {string} content
* @param {Object} [options={}]
* @returns {this}
*/
lineText(content, options = {}) {
return this.text(content, options).newline()
}
/**
* 换行
* @param {number} [lines=1] - 换行行数
* @returns {this}
*/
newline(lines = 1) {
for (let i = 0; i < lines; i++) {
this.pushCommand(ESC_POS_COMMANDS.LF)
}
return this
}
/**
* 添加分隔线
* @param {string} [char='-'] - 分隔符字符
* @returns {this}
*/
separator(char = "-") {
const repeatCount = char.length ? Math.floor(this.pageWidth / char.length) : 0
if (repeatCount <= 0) return this
const line = char.repeat(repeatCount)
// 保存当前对齐方式
const prevAlign = this.currentState.align
// 临时设置为居中
this.align("center")
this.text(line)
this.newline()
// 恢复原对齐方式
if (prevAlign !== "center") {
this.align(prevAlign)
}
return this
}
/**
* 切纸
* @param {'full' | 'partial'} [type='full'] - 切纸类型
* @returns {this}
*/
cut(type = "full") {
if (type === "partial") {
this.pushCommand(ESC_POS_COMMANDS.CUT_PARTIAL)
} else {
this.pushCommand(ESC_POS_COMMANDS.CUT_FULL)
}
return this
}
/**
* 构建最终字节流
* @returns {Uint8Array}
*/
build() {
return new Uint8Array(this.commands)
}
/**
* 获取指令长度
* @returns {number}
*/
getLength() {
return this.commands.length
}
/**
* 清空指令
* @returns {this}
*/
clear() {
this.commands = []
return this
}
/**
* 推送指令到命令列表
* @param {number[]} command
* @private
*/
pushCommand(command) {
this.commands.push(...command)
}
/**
* 设置对齐方式
* @param {'left' | 'center' | 'right'} alignment
* @private
*/
align(alignment) {
const alignmentMap = {
left: ESC_POS_COMMANDS.ALIGN_LEFT,
center: ESC_POS_COMMANDS.ALIGN_CENTER,
right: ESC_POS_COMMANDS.ALIGN_RIGHT
}
const alignCommand = alignmentMap[alignment]
if (alignCommand) {
this.pushCommand(alignCommand)
this.currentState.align = alignment // 修正:同步更新 currentState 的 align 状态
}
}
/**
* 设置粗体
* @param {boolean} [enable=true]
* @private
*/
bold(enable = true) {
if (enable) {
this.pushCommand(ESC_POS_COMMANDS.BOLD_ON)
} else {
this.pushCommand(ESC_POS_COMMANDS.BOLD_OFF)
}
}
/**
* 设置字体大小
* @param {number} font
* @private
*/
size(font) {
if (font < 1 || font > 8) {
return
}
const n = ((font - 1) << 4) | (font - 1)
this.pushCommand([...ESC_POS_COMMANDS.FONT, n])
}
/**
* 设置行间距
* @param {number} spacing
* @private
*/
lineSpacing(spacing) {
this.pushCommand([...ESC_POS_COMMANDS.LINE_SPACING, spacing])
}
/**
* 应用样式差异
* @param {Object} next
* @private
*/
applyDiffStyle(next) {
// bold
if (next.bold !== this.currentState.bold) {
this.bold(next.bold)
this.currentState.bold = next.bold
}
// size
if (next.size !== this.currentState.size) {
this.size(next.size)
this.currentState.size = next.size
}
// line spacing
if (next.lineSpacing !== this.currentState.lineSpacing) {
this.lineSpacing(next.lineSpacing)
this.currentState.lineSpacing = next.lineSpacing
}
}
}
使用示例
import { ESCPOSGenerator } from "./PrintJob"
const testFn = () => {
// 创建配送回单打印任务
const printJob = new ESCPOSGenerator()
const printData = printJob
.init() // 初始化打印机
.text("配送回单", { bold: true, size: 2, align: "center" }) // 文本
.newline(2) // 空两行
.cut() // 切纸
.build() // 构建字节流
console.log(`打印数据大小: ${printData.length} 字节`)
}
小结
本章系统介绍了热敏打印的事实标准------ESC/POS 指令模型,包括:
- 指令基础:ESC/POS 的组成结构与流式执行特性。
- 常用命令速查:以 JavaScript 对象形式整理了初始化、换行、切纸、对齐、加粗等高频指令。
- 文本编码转换:解释了小程序环境下中文乱码的根源,并给出了基于 text-encoding-gbk 的 GBK 编码转换方案。
- 工程化封装 :提供了一个完整的
PrintJob类实现,将复杂的指令拼接隐藏在语义化的链式 API 之后,同时输出小程序可直接使用的十六进制字符串。
指令构建完成之后,文本指令的发送链路已经打通,接下来让我们看看另一个常见但更复杂的场景:图片打印。
六、BLE 数据传输机制
在完成 BLE 连接建立以及 ESC/POS 指令构建之后,打印流程才真正进入核心阶段。
需要再次强调一个关键认知:
BLE 打印并不是一次"写入字符串"的操作,而是一套受协议严格约束的数据传输过程。
这一过程涉及分包、顺序控制、发送节奏以及可靠性保障等多个方面。
BLE 的分包通信模型
BLE 并非为大数据连续传输而设计,其底层采用的是基于 MTU(Maximum Transmission Unit)的分包通信模型。
在协议栈中:
- 应用层数据通过 ATT(Attribute Protocol)承载
- ATT 层定义了单次传输的最大数据长度(MTU)
MTU 与有效载荷
根据蓝牙核心规范,ATT 协议的默认 MTU 为 23 字节。这 23 字节的构成如下:
| 组成部分 | 字节数 | 说明 |
|---|---|---|
| Opcode | 1 字节 | 操作码(如 Write Request = 0x12) |
| Attribute Handle | 2 字节 | 特征值句柄 |
| 有效载荷 | 20 字节 | 应用层实际可用的数据 |
因此,应用层单次写入的实际可用数据量仅为 20 字节。这意味着,即使发送一条简单的"打印文本"指令,也可能被拆分为多个数据包。
跨平台 MTU 差异
MTU 并非固定不变,连接建立后双方可协商更大的 MTU 值。不同平台的 MTU 能力存在显著差异:
| 平台 | MTU 协商能力 | 最大 MTU | 说明 |
|---|---|---|---|
| Android | requestMtu(int mtu) |
512 字节 | Android 5.1+ 支持主动协商 |
| iOS | 系统自动协商 | 185 字节 | 无开放 API,由外设发起协商 |
| 小程序 | 不支持协商 | 23 字节(有效 20 字节) | API 未提供 MTU 协商接口 |
小程序的关键约束:
writeBLECharacteristicValueAPI 要求单次写入数据"限制在 20 字节内"。- 小程序未提供 MTU 协商相关 API,开发者无法在应用层主动请求提升 MTU。
- 这意味着小程序环境下的 BLE 通信,始终受限于默认的 20 字节单包上限。
分包策略:指令切片与顺序发送
受限于单包 20 字节的约束,完整 ESC/POS 指令流必须被切分为多个小包。分包的核心原则:
-
按顺序切片:将完整的 hex 字符串按 40 个字符(对应 20 字节)为一组进行切分。
-
保持顺序:分包必须严格按原始顺序发送,保证打印机接收的指令顺序正确。
-
最后一包处理:最后一包可能不足 20 字节,直接发送剩余部分。
// 将完整指令流切分为 20 字节的分包
function splitIntoPackets(command) {
let bytes
if (command instanceof ArrayBuffer) {
bytes = new Uint8Array(command)
} else if (Array.isArray(command)) {
bytes = new Uint8Array(command)
} else if (command && command.buffer instanceof ArrayBuffer) {
bytes = new Uint8Array(command.buffer, command.byteOffset, command.byteLength)
} else {
bytes = new Uint8Array()
}const packets = []
for (let i = 0; i < bytes.length; i += 20) {
packets.push(bytes.slice(i, i + 20).buffer)
}
return packets
}
发送节奏控制与队列管理
蓝牙缓冲区
打印机内部并不是"收到数据就立刻执行",而是有一个临时存储区域:
蓝牙接收缓冲区(Bluetooth RX Buffer)
它的作用是:
- 暂存 BLE 发送过来的数据
- 再交给打印引擎逐条解析执行
可以理解为:
BLE 是"快递员",缓冲区是"收件筐",打印机是"处理工人"
为什么会发生溢出?
问题出在一个"速度不匹配":
⚡ BLE 发送速度:
- 可以连续快速 write
- 无响应模式甚至几乎不等待
🐢 打印机处理速度
- 需要解析 ESC/POS 指令
- 热敏头逐行打印
- 图片还要逐点绘制
💥** 结果就是:**
当你发送速度 > 打印机处理速度时:
📌 缓冲区被塞满 → 新数据进不来 → 旧数据被覆盖或丢弃
队列管理方案
Write Without Response 模式下,若连续写入速度过快,可能导致打印机蓝牙模块缓冲区溢出而丢包。因此必须控制发送节奏。
class BLEPacketQueue {
constructor(deviceId, serviceId, characteristicId) {
this.queue = []
this.isSending = false
this.deviceId = deviceId
this.serviceId = serviceId
this.characteristicId = characteristicId
}
// 添加分包到队列
addPackets(packets) {
this.queue.push(...packets)
if (!this.isSending) {
this.sendNext()
}
}
// 发送下一包
sendNext() {
if (this.queue.length === 0) {
this.isSending = false
console.log("所有分包发送完成")
return
}
this.isSending = true
const packet = this.queue.shift()
console.log("packet: ", packet)
uni.writeBLECharacteristicValue({
deviceId: this.deviceId,
serviceId: this.serviceId,
characteristicId: this.characteristicId,
value: packet,
success: () => {
// 发送成功后延时 15-20ms,再发送下一包
setTimeout(() => this.sendNext(), 20)
},
fail: err => {
console.error("分包发送失败", err)
// 可在此处实现重试逻辑
this.queue.unshift(packet) // 放回队列头部
setTimeout(() => this.sendNext(), 50)
}
})
}
}
发送间隔的实践经验:
- 间隔过小(<10ms)可能导致打印机缓冲区溢出,表现为乱码或丢包。
- 间隔过大则延长整体打印时间,影响配送员体验。
- 15-20ms 是一个经过实践验证的平衡值。
Write Without Response 的可靠性与流控权衡
打印场景通常选用 Write Without Response 以提升吞吐效率。但这一模式放弃了应用层的单包确认,可靠性依赖于底层链路层的重传机制。
在工程实践中,可通过以下策略平衡可靠性与效率:
-
发送间隔控制:给打印机蓝牙模块留出处理时间。
-
Notify 状态监听:通过监听打印机的"缓冲区满/可接收"状态,实现应用层流控。
-
整单校验:打印完成后,通过 Notify 接收"打印完成"确认。若超时未收到,触发重打逻辑。
function waitForPrintComplete() {
return new Promise(resolve => setTimeout(resolve, 500))
}const printTestPage = () => {
try {
const generator = new ESCPOSGenerator()
return generator
.init()
.lineText("蓝牙打印机测试页", { align: "center", bold: true, size: 2 })
.separator()
.lineText("设备 MAC: " + activeDeviceId.value)
.lineText("测试文本: ABCDefgh12345")
.lineText("样式测试 - 居左粗体", { align: "left", bold: true })
.lineText("样式测试 - 居中大小2", { align: "center", size: 2 })
.lineText("样式测试 - 居右正常", { align: "right" })
.separator()
.newline(3)
.cut()
.build()
} catch (e) {
console.log(e)
return new Uint8Array()
}
}async function printOrder() {
if (!activeDeviceId.value || !activeServiceId.value || !activeWriteCharId.value) {
uni.showToast({
title: "请先连接打印设备",
icon: "none"
})
return
}// 1. 构建指令流
const command = printTestPage()// 2. 分包
const packets = splitIntoPackets(command)
console.log("command: ", command)
console.log("packets: ", packets)// 3. 队列发送
const queue = new BLEPacketQueue(activeDeviceId.value, activeServiceId.value, activeWriteCharId.value)
queue.addPackets(packets)// 4. 等待完成通知
await waitForPrintComplete()uni.showToast({
title: "已发送测试打印",
icon: "success"
})
}
小结
本章从协议层到工程实现,系统说明了 BLE 打印的数据传输机制:
- BLE 基于 MTU 的分包通信模型
- 钉钉小程序 20 字节硬限制
- hex 数据格式转换
- 分包切片策略
- 队列发送与节奏控制
- 应用层可靠性与流控设计
通过这些机制,才能在 BLE 受限环境下,实现稳定的打印数据传输。
那么,如何在同样的 BLE 限制下,高效传输体积更大、数据更密集的图片内容?
<template>
<view>
<scroll-view scroll-y class="box">
<view class="item" v-for="item in blueDeviceList" :key="item.deviceId" @click="connectBluetooth(item.deviceId)">
<view>
<text>id: {{ item.deviceId }}</text>
</view>
<view>
<text>name: {{ item.name }}</text>
</view>
</view>
</scroll-view>
<button @click="resetBluetoothAdapter">重置蓝牙</button>
<button @click="openBluetoothAdapter">1 初始化蓝牙</button>
<button @click="startBluetoothDevicesDiscovery">2 搜索附近蓝牙设备</button>
<button @click="releaseBluetoothResources">销毁</button>
<button @click="testFn">测试</button>
<button @click="printOrder">打印测试页</button>
</view>
</template>
<script setup>
import { onMounted, ref } from "vue" // 补上了 ref 的引入
import { ESCPOSGenerator } from "./PrintJob"
const testFn = () => {
// 创建配送回单打印任务
const printJob = new ESCPOSGenerator()
const printData = printJob
.init() // 初始化打印机
.text("配送回单", { bold: true, size: 2, align: "center" }) // 文本
.newline(2) // 空两行
.cut() // 切纸
.build() // 构建字节流
console.log(`打印数据大小: ${printData.length} 字节`)
}
let timeoutId = null
const timeout = 5000
// 搜索到的蓝牙设备列表
const blueDeviceList = ref([])
// 新增:全局记录当前连接的设备 ID
const activeDeviceId = ref("")
const activeServiceId = ref("")
const activeWriteCharId = ref("")
onMounted(() => {
uni.onBluetoothAdapterStateChange(res => {
console.log("uni.onBluetoothAdapterStateChange", res)
const { available } = res
if (!available) {
uni.showModal({
title: "提示",
content: "系统蓝牙未开启"
})
}
})
uni.onBluetoothDeviceFound(res => {
res.devices.forEach(device => {
device.name && blueDeviceList.value.push(device)
})
})
})
const openBluetoothAdapter = () => {
return new Promise((resolve, reject) => {
uni.openBluetoothAdapter({
success() {
resolve()
},
fail(err) {
if (err.errCode === 10001) {
uni.showToast({
title: "蓝牙未开启",
icon: "none"
})
}
reject(err)
}
})
})
}
const startBluetoothDevicesDiscovery = () => {
return new Promise((resolve, reject) => {
uni.startBluetoothDevicesDiscovery({
allowDuplicatesKey: false, // 是否允许重复上报同一设备, true 表示允许
interval: 0, // 搜索间隔 (1000ms ~ 120000ms)
success() {
timeoutId = setTimeout(() => {
resolve()
stopBluetoothDevicesDiscovery()
clearTimeout(timeoutId)
}, timeout)
},
fail(err) {
reject(err)
}
})
})
}
const stopBluetoothDevicesDiscovery = () => {
return new Promise((resolve, reject) => {
uni.stopBluetoothDevicesDiscovery({
success() {
resolve()
},
fail(err) {
reject(err)
}
})
})
}
const closeBluetoothAdapter = () => {
return new Promise((resolve, reject) => {
uni.closeBluetoothAdapter({
success() {
resolve()
},
fail(err) {
reject(err)
}
})
})
}
const resetBluetoothAdapter = async () => {
// 清空搜索到的蓝牙设备列表
blueDeviceList.value = []
// 清空当前连接的 ID
activeDeviceId.value = ""
activeServiceId.value = ""
activeWriteCharId.value = ""
// 清除定时器
clearTimeout(timeoutId)
// 停止搜索附近蓝牙设备
await stopBluetoothDevicesDiscovery()
// 关闭蓝牙模块
await closeBluetoothAdapter()
// 等待系统释放资源(非常重要)
await new Promise(resolve => setTimeout(resolve, 300))
// 重新初始化蓝牙模块
await openBluetoothAdapter()
uni.showToast({
title: "蓝牙已重置",
icon: "success"
})
}
const connectBluetooth = async deviceId => {
uni.createBLEConnection({
deviceId,
success() {
console.log("设备连接成功")
// 核心改动:连接成功后保存当前设备 ID
activeDeviceId.value = deviceId
activeServiceId.value = ""
activeWriteCharId.value = ""
// 连接成功后立即停止扫描,避免干扰后续操作
stopBluetoothDevicesDiscovery()
// 获取服务列表
getDeviceServices(deviceId)
},
fail(err) {
console.error("连接失败", err)
}
})
}
function getDeviceServices(deviceId) {
uni.getBLEDeviceServices({
deviceId,
success(res) {
console.log("Services列表:", res.services)
// 通常打印服务使用自定义 UUID
const services = res.services.sort((a, b) => getServicePriority(a.uuid) - getServicePriority(b.uuid))
const printService = services.find(service => service.uuid.includes("FF00") || service.isPrimary)
if (printService) {
getServiceCharacteristics(deviceId, printService.uuid)
}
},
fail(err) {
console.error("获取服务失败", err)
}
})
}
function getServiceCharacteristics(deviceId, serviceId) {
console.log("deviceId", deviceId)
console.log("serviceId", serviceId)
uni.getBLEDeviceCharacteristics({
deviceId,
serviceId,
success(res) {
console.log("Characteristics列表:", res.characteristics)
let writeCharId = null
let notifyCharId = null
res.characteristics.forEach(c => {
if (c.properties.write || c.properties.writeWithoutResponse) {
writeCharId = c.uuid
}
if (c.properties.notify || c.properties.indicate) {
notifyCharId = c.uuid
}
})
console.log("writeCharId", writeCharId)
console.log("notifyCharId", notifyCharId)
activeServiceId.value = serviceId
activeWriteCharId.value = writeCharId || ""
// 保存特征值 ID,启用 Notify
enableNotify(deviceId, serviceId, notifyCharId)
},
fail(err) {
console.error("获取特征值失败", err)
}
})
}
function enableNotify(deviceId, serviceId, characteristicId) {
uni.notifyBLECharacteristicValueChange({
deviceId,
serviceId,
characteristicId,
state: true, // 启用 notify
success() {
console.log("Notify 已启用")
// 监听特征值变化事件
uni.onBLECharacteristicValueChange(res => {
const hexStr = res.value // 返回 hex 字符串
console.log("Notify 收到数据", hexStr)
})
},
fail(err) {
console.error("启用 Notify 失败", err)
}
})
}
const getServicePriority = uuid => {
const u = uuid.toLowerCase()
// 提取短 UUID(如 0000fff0)
const shortUUID = u.startsWith("0000") ? u.slice(4, 8) : ""
// 1. 标准服务(最低优先级)
const standardServices = ["1800", "1801", "180a", "180f"]
if (standardServices.includes(shortUUID)) {
return 100
}
// 2. 打印透传服务(最高优先级)
if (shortUUID.startsWith("ff")) {
return 0
}
// 3. 纯 128 位厂商 UUID(中优先级,需验证)
if (!u.includes("0000-1000-8000-00805f9b34fb")) {
return 50
}
// 4. 其他标准扩展服务
return 60
}
// 核心改动:修改后的资源清理方法
const releaseBluetoothResources = () => {
// 1. 停止搜索设备(如果还在搜索中)
stopBluetoothDevicesDiscovery()
// 2. 断开与当前已连接蓝牙设备的连接
if (activeDeviceId.value) {
uni.closeBLEConnection({
deviceId: activeDeviceId.value,
success: () => {
console.log("成功断开设备连接")
},
fail: err => {
console.error("断开设备连接失败", err)
}
})
}
// 3. 最后,关闭蓝牙适配器,彻底释放系统资源
closeBluetoothAdapter()
.then(() => {
console.log("蓝牙适配器已关闭,资源已释放")
// 重置所有连接相关状态
activeDeviceId.value = ""
activeServiceId.value = ""
activeWriteCharId.value = ""
blueDeviceList.value = []
})
.catch(err => {
console.error("关闭蓝牙适配器失败", err)
})
}
class BLEPacketQueue {
constructor(deviceId, serviceId, characteristicId) {
this.queue = []
this.isSending = false
this.deviceId = deviceId
this.serviceId = serviceId
this.characteristicId = characteristicId
}
// 添加分包到队列
addPackets(packets) {
this.queue.push(...packets)
if (!this.isSending) {
this.sendNext()
}
}
// 发送下一包
sendNext() {
if (this.queue.length === 0) {
this.isSending = false
console.log("所有分包发送完成")
return
}
this.isSending = true
const packet = this.queue.shift()
console.log("packet: ", packet)
uni.writeBLECharacteristicValue({
deviceId: this.deviceId,
serviceId: this.serviceId,
characteristicId: this.characteristicId,
value: packet,
success: () => {
// 发送成功后延时 15-20ms,再发送下一包
setTimeout(() => this.sendNext(), 20)
},
fail: err => {
console.error("分包发送失败", err)
// 可在此处实现重试逻辑
this.queue.unshift(packet) // 放回队列头部
setTimeout(() => this.sendNext(), 50)
}
})
}
}
// 将完整指令流切分为 20 字节的分包
function splitIntoPackets(command) {
let bytes
if (command instanceof ArrayBuffer) {
bytes = new Uint8Array(command)
} else if (Array.isArray(command)) {
bytes = new Uint8Array(command)
} else if (command && command.buffer instanceof ArrayBuffer) {
bytes = new Uint8Array(command.buffer, command.byteOffset, command.byteLength)
} else {
bytes = new Uint8Array()
}
const packets = []
for (let i = 0; i < bytes.length; i += 20) {
packets.push(bytes.slice(i, i + 20).buffer)
}
return packets
}
function waitForPrintComplete() {
return new Promise(resolve => setTimeout(resolve, 500))
}
const printTestPage = () => {
try {
const generator = new ESCPOSGenerator()
return generator
.init()
.lineText("蓝牙打印机测试页", { align: "center", bold: true, size: 2 })
.separator()
.lineText("设备 MAC: " + activeDeviceId.value)
.lineText("测试文本: ABCDefgh12345")
.lineText("样式测试 - 居左粗体", { align: "left", bold: true })
.lineText("样式测试 - 居中大小2", { align: "center", size: 2 })
.lineText("样式测试 - 居右正常", { align: "right" })
.separator()
.newline(3)
.cut()
.build()
} catch (e) {
console.log(e)
return new Uint8Array()
}
}
async function printOrder() {
if (!activeDeviceId.value || !activeServiceId.value || !activeWriteCharId.value) {
uni.showToast({
title: "请先连接打印设备",
icon: "none"
})
return
}
// 1. 构建指令流
const command = printTestPage()
// 2. 分包
const packets = splitIntoPackets(command)
console.log("command: ", command)
console.log("packets: ", packets)
// 3. 队列发送
const queue = new BLEPacketQueue(activeDeviceId.value, activeServiceId.value, activeWriteCharId.value)
queue.addPackets(packets)
// 4. 等待完成通知
await waitForPrintComplete()
uni.showToast({
title: "已发送测试打印",
icon: "success"
})
}
</script>
<style>
.box {
width: 98%;
height: 400rpx;
box-sizing: border-box;
margin: 0 auto 20rpx;
border: 2px solid dodgerblue;
}
.item {
box-sizing: border-box;
padding: 10rpx;
border-bottom: 1px solid #ccc;
}
button {
margin-bottom: 20rpx;
}
.msg_x {
border: 2px solid seagreen;
width: 98%;
margin: 10rpx auto;
box-sizing: border-box;
padding: 20rpx;
}
.msg_x .msg_txt {
margin-bottom: 20rpx;
}
</style>