小程序蓝牙打印探索与实践(下)

五、ESC/POS 指令模型

打印机本质上是一个顺序执行的硬件设备

  • 不解析 HTML
  • 不理解 JSON
  • 不具备页面布局能力

它唯一能够处理的,是一段按顺序输入的字节流(Byte Stream)

因此,要驱动打印机完成打印任务,必须将业务数据转换为其所支持的打印控制语言。在便携式热敏打印机领域,这一标准就是 ESC/POS

ESC/POS 概述

ESC/POS 是由 EPSON 定义的一套打印控制指令体系,现已成为热敏票据打印的事实标准。

其核心特征是:

  • 以控制字符开头:
    • ESC(0x1B)
    • GS(0x1D)
  • 后跟一个或多个参数字节
  • 构成一条完整指令

控制指令通常由以下几部分组成:

指令流结构

一条完整的 ESC/POS 指令流通常由以下部分组成:

  1. 初始化命令:复位打印机状态
  2. 格式控制命令:对齐、字体、加粗、行距等
  3. 内容数据:文本、条码、二维码、图片
  4. 结束控制命令:走纸、切纸
流式执行模型

打印机采用流式处理机制

边接收 → 边解析 → 边执行

不存在"完整接收后再统一执行"的过程。

因此:

👉 指令发送顺序必须严格等于打印顺序

常用 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
  • 而大多数便携式热敏打印机只支持 GBKGB2312 这类中文字符集。

如果直接将 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 协商接口

小程序的关键约束

  • writeBLECharacteristicValue API 要求单次写入数据"限制在 20 字节内"。
  • 小程序未提供 MTU 协商相关 API,开发者无法在应用层主动请求提升 MTU。
  • 这意味着小程序环境下的 BLE 通信,始终受限于默认的 20 字节单包上限

分包策略:指令切片与顺序发送

受限于单包 20 字节的约束,完整 ESC/POS 指令流必须被切分为多个小包。分包的核心原则:

  1. 按顺序切片:将完整的 hex 字符串按 40 个字符(对应 20 字节)为一组进行切分。

  2. 保持顺序:分包必须严格按原始顺序发送,保证打印机接收的指令顺序正确。

  3. 最后一包处理:最后一包可能不足 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 以提升吞吐效率。但这一模式放弃了应用层的单包确认,可靠性依赖于底层链路层的重传机制。

在工程实践中,可通过以下策略平衡可靠性与效率:

  1. 发送间隔控制:给打印机蓝牙模块留出处理时间。

  2. Notify 状态监听:通过监听打印机的"缓冲区满/可接收"状态,实现应用层流控。

  3. 整单校验:打印完成后,通过 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>
相关推荐
00后程序员张1 小时前
Jenkins 自动上传 IPA 到 App Store 把发布步骤融入 CI/CD
android·ios·小程序·https·uni-app·iphone·webview
DolphinScheduler社区3 小时前
Apache DolphinScheduler 3.4.2 正式发布!新增 Amazon EMR Serverless 插件,增强监控与补数据能力
大数据·云原生·serverless·apache·海豚调度·版本发版
前端 贾公子3 小时前
小程序蓝牙打印探索与实践(中)
apache
SeaTunnel3 小时前
87 个 PR 迭代复盘|Apache SeaTunnel 5 月版本重点更新解读
大数据·数据库·开源·apache·seatunnel
DolphinScheduler社区3 小时前
实战演示 | 基于 Apache DolphinScheduler 与 Apache SeaTunnel 实现 MySQL 到 Doris 离线定时增量同步
数据库·mysql·开源·apache·海豚调度·大数据工作流调度
chéng ௹4 小时前
uniapp封装火山引擎 DataRangers 埋点 SDK
uni-app·apache·火山引擎
阿坤带你走近大数据4 小时前
Apache Hop的详细介绍
apache
万岳科技系统开发4 小时前
骑手配送系统如何支持外卖与跑腿一体化运营
大数据·前端·小程序
2501_915909064 小时前
iOS IPA文件反编译与打包操作方法详解
android·ios·小程序·https·uni-app·iphone·webview