# V8.Print 蓝牙打印运行指南（GP-M322 / ZICOX CC4）

本参考用于 Microi 前端 V8 的 BLE/SPP 标签和小票打印。运行时事实源为
`Microi.Client/src/utils/v8-print.js`，型号与协议适配事实源为
`Microi.Client/src/utils/ble/printer-compatibility.js`，指令方法事实源见
[`bluetooth-print-api.md`](bluetooth-print-api.md)。官网旧业务示例不能作为当前连接语义。

## 目录

- [前端挂载范围](#前端挂载范围)
- [微服务中的平台普通打印桥](#微服务中的平台普通打印桥)
- [运行环境与能力判断](#运行环境与能力判断)
- [双型号兼容契约](#双型号兼容契约)
- [连接与发送语义](#连接与发送语义)
- [最小安全流程](#最小安全流程)
- [批量打印与恢复](#批量打印与恢复)
- [兼容性与当前限制](#兼容性与当前限制)
- [安全边界](#安全边界)
- [实机验收](#实机验收)

## 前端挂载范围

当前主前端有三层真实挂载，均调用幂等的 `initV8Print(V8)`：

| 源码位置 | 覆盖的 V8 场景 |
|---|---|
| `src/utils/diy.common.js` | 通用前端 V8 基础对象和常规按钮流程 |
| `src/views/form-engine/diy-form.vue` | 表单、字段及表单按钮 V8 |
| `src/views/form-engine/diy-table.vue` | 列表、菜单按钮、行按钮等表格 V8 |

因此不要再从租户脚本导入 `tsc.js`、`esc.js` 或自行挂载 `V8.Print`。这些能力只在
Microi 浏览器/5+App 前端 V8 中可用，不属于后端接口引擎、后端表单事件或微信小程序
原生 BLE API。

三个入口都会取得同一个应用级 `Print` 单例。分包游标、打印份数和连接引用仍是可变状态，
但 `prepareSend` 已把所有前端 V8 上下文排进同一条运行时发送队列。业务代码仍应逐次
`await` 保持明确的结果顺序，不能认为不同按钮或不同 V8 对象彼此隔离。

PC/平板顶部导航和移动端【我的】页的蓝牙入口也使用该单例：它们展示实时连接状态和设备名，
点击后复用 `OpenBluetoothPage()`，因此用户可以先在全局入口连接，再进入任意模块执行 V8 打印。

## 微服务中的平台普通打印桥

隔离运行的 MicroService 不能直接访问父页面 `V8.Print`、Vue 组件或打印弹窗。微服务页面需要
提供“普通打印”时，调用 `microi.host.v1` 的 `openPlatformPrint` 宿主动作；参数为当前租户
`mic_print.Id`、标题，以及当前 `apiBase` 同源、路径以 `/apiengine/` 开头并明确携带同一
`OsClient` 的绝对数据地址。禁止把 Token、帐号密码或其它凭据放进 URL。

`openPlatformPrint` 只打开 Print Engine 预览，不发送 TSPL/CPCL/ESC-POS；它不是蓝牙代理，
也不发送 BLE/SPP 字节。
宿主返回 `accepted:true` 只表示预览已打开，不代表浏览器已打印或打印机已出纸。独立运行没有
宿主能力时应隐藏/禁用该入口。完整调用示例与结果协议见
`../../microi-microservice/references/runtime-delivery.md` 的“微服务调用平台普通打印”。

## 运行环境与能力判断

| 运行环境 | 当前引擎 | 结论 |
|---|---|---|
| Android 5+App | `plus.bluetooth` + `plus.android` | BLE；ZICOX CC4 可回退到已配对 RFCOMM/SPP；Android 12+ 需要附近设备权限 |
| iOS 5+App | `plus.bluetooth` | BLE，不使用 Android SPP |
| 存在 `navigator.bluetooth.requestDevice` 的浏览器 | Web Bluetooth | 支持；通常要求安全上下文和用户手势 |
| 其它普通 H5/浏览器 | 无 | `V8.Print` 仍可能存在，但连接页会提示能力不可用 |
| 微信小程序原生 BLE | 不属于此模块 | 需要小程序/UniApp 侧专用实现 |

不要用 `V8.ClientType === 'PC'` 判断蓝牙能力，也不要只检查
`BLEInformation.deviceId`。正确顺序是检查 `V8.Print`、调用 `isConnected()`，再在
用户点击事件中 `await OpenBluetoothPage()`。

Android 12+ 宿主必须启用 DCloud Bluetooth 模块并声明 `BLUETOOTH_SCAN`、
`BLUETOOTH_CONNECT`。运行时只在用户主动点击“搜索”时申请，不得在页面初始化或自动重连
时弹授权框；拒绝或永久拒绝要引导用户进入系统“附近的设备”权限设置。

浏览器模板、PDF、A4 单据和 Print Engine JSON 属于 `print-engine`；TSC/TSPL 或
CPCL/ESC/POS 原生字节通过 BLE/SPP 写入才属于 `V8.Print`。

## 双型号兼容契约

| 型号 | 标签协议 | 传输 | 兼容承诺 |
|---|---|---|---|
| 佳博 GP-M322 | TSPL | BLE | `createNew().getData()` 原字节发送，协议适配层不得改写 |
| ZICOX CC4 | CPCL | BLE 优先，Android SPP 兜底 | 同一份标准 TSC 高层调用在首包写入前转换为 CPCL |
| 其它 TSPL | TSPL | BLE | 保持原字节路径 |

`createNewESC()` 在 CC4 上也原样发送，因为厂家声明 CC4 支持 ESC/POS。不要根据厂家 Demo
里存在测试字符串就擅自宣称其它协议；以产品页、准确手册和实机固件为准。

TSC 构建器给字节数组附加不可枚举的操作元数据，因此 GP 字节值、长度与数组枚举完全不变。
CC4 必须直接收到同一次 `getData()` 返回值；`Array.from`、展开、JSON 序列化等复制会丢失
元数据并失败关闭。适配器先完成整份转换和校验，再开始分包；不支持的命令不得产生半张输出。

CC4 可转换：纸张尺寸、速度、浓度、间隙/黑标、前后走纸、方向 0/1、参考点、线/框/反相、
文字、条码、二维码、位图及单次 `setPagePrint`。`init`/`setCls` 无需输出。原始 `addCommand`、
国家/代码页、`setFromfeed`、`setHome`、蜂鸣、限位、擦除和未知方法没有足够等价语义，必须
在首包写入前拒绝。扩展白名单前要同时增加协议单测和两台目标机回归。

Android SPP 与厂家 Demo 一致，优先 RFCOMM 通道 1，再以标准 UUID
`00001101-0000-1000-8000-00805F9B34FB` 兜底。自动模式只显示名称可识别为 CC4 的已配对
经典设备；广播名不规范时，用户先手工选择 `zicox-cc4`。Web Bluetooth 不能访问 SPP。

## 连接与发送语义

| API | 当前真实语义 |
|---|---|
| `createNew()` | 新建 TSC/TSPL 标签指令构建器 |
| `createNewESC()` | 新建 ESC/POS 小票指令构建器 |
| `OpenBluetoothPage()` | 返回 `Promise<boolean>`；在连接弹窗关闭时解析，重复打开复用同一个 Promise |
| `isConnected()` | Web 端检查实时 GATT 与写特征；5+App 结合连接事件在线标记与设备/写特征 ID |
| `reconnect()` | 使用已记住的设备 ID 或浏览器保留的设备授权重连，不弹选择框 |
| `getConnectionState()` | 返回可展示的连接、记忆、设备、错误和重连状态快照 |
| `subscribeConnection(listener)` | 立即回调当前快照并持续通知状态变化，返回取消订阅函数 |
| `getPrinterProfile()` | 返回最终型号、标签指令和传输偏好；普通业务无需调用 |
| `setPrinterProfile(mode)` | 手工选 `gprinter-gp-m322`、`zicox-cc4`、`generic-tspl`，或恢复 `auto` |
| `prepareSend(bytes)` | 先恢复连接、完成型号协议适配，再进入应用级队列按包串行写入 |
| `Send(bytes)` | 依赖 `prepareSend` 已设置的内部游标，属于内部状态机入口，业务代码不要直接调用 |
| `setOneTimeData(bytes)` | 设置 BLE 包长；只接受 1–512 整数，连接页候选 20–190，默认 20 |
| `setPrinterNum(num)` | 重复发送同一缓冲区；只接受 1–99 整数，连接页候选 1–9 |
| `disconnect()` | 主动断开、停止自动重连并忘记当前设备 |
| `BLEInformation` | 最近设备/型号/通道/服务/特征元数据，只用于诊断，不代表实时连接或打印回执 |

`getConnectionState()` 额外含 `transport`、`profileMode`、`profileId`、`profileName`、
`commandLanguage`；前端展示可以使用，打印判断仍使用 `isConnected()`。

`OpenBluetoothPage()` 不是“连接成功事件”；用户连上设备后仍要关闭弹窗，调用方才能继续。
设备元数据会写入 `localStorage` 与兼容用 `sessionStorage`。应用初始化、页面恢复、重新获得
焦点和意外断线时会做有限次数自动重连：5+App 使用设备 ID；Web 端只有浏览器保留授权且
支持 `navigator.bluetooth.getDevices()` 时才可无弹窗恢复。系统蓝牙、浏览器权限、设备电源、
休眠、距离等仍会造成真实断线；重试结束后必须让用户从全局入口重新选择。

## 最小安全流程

```javascript
function cleanCommandText(value, maxLength) {
  return String(value == null ? '' : value)
    .replace(/[\r\n"\x00-\x1f]/g, ' ')
    .slice(0, maxLength || 120);
}

async function ensurePrinterConnected() {
  if (!V8.Print) throw new Error('当前前端未加载蓝牙打印能力');
  if (V8.Print.isConnected()) return;

  var connected = await V8.Print.reconnect();
  if (!connected) connected = await V8.Print.OpenBluetoothPage();
  if (!connected || !V8.Print.isConnected()) {
    throw new Error('未连接蓝牙打印机');
  }
}

async function printLabel(order) {
  await ensurePrinterConnected();

  var cmd = V8.Print.createNew();
  cmd.setSize(60, 40);
  cmd.setGap(2);
  cmd.setSpeed(4);
  cmd.setDensity(8);
  cmd.setDirection(1);
  cmd.setCls();
  cmd.setText(20, 20, 'TSS24.BF2', 1, 1, cleanCommandText(order.Name, 40));
  cmd.setBarCode(20, 80, '128', 60, 1, 2, 2, cleanCommandText(order.Code, 40));
  cmd.setQR(340, 30, 'L', 5, 'A', cleanCommandText(order.Id, 120));
  cmd.setPagePrint();

  await V8.Print.prepareSend(cmd.getData());
}
```

同一段标签代码在 GP-M322 上保持 TSPL，在 CC4 上转换为 CPCL。ESC/POS 小票使用
`createNewESC()`，完整顺序和 25 个真实方法见
[`bluetooth-print-api.md`](bluetooth-print-api.md)。发送成功只表示 BLE/SPP 写调用完成，不能
写成“打印机已走纸”或“物理打印成功”。当前源码虽发现 read/notify 特征，但没有订阅状态
通知，也没有消费 ACK、缺纸或故障回执。

## 批量打印与恢复

```javascript
async function printBatch(rows, startIndex) {
  var list = Array.isArray(rows) ? rows : [];
  var begin = Math.max(0, Number(startIndex || 0));
  var limit = Math.min(list.length, begin + 100);

  for (var i = begin; i < limit; i++) {
    try {
      await printLabel(list[i]);
      V8.Tips('已发送 ' + (i + 1) + '/' + list.length, true);
    } catch (error) {
      return {
        Code: 0,
        Msg: '第 ' + (i + 1) + ' 条发送失败：' + (error.message || error),
        NextIndex: i
      };
    }
  }

  return { Code: 1, Data: { NextIndex: limit, HasMore: limit < list.length } };
}
```

- 不用固定 `setTimeout(3000)` 猜测上一张是否完成。
- 不用 `Promise.all` 表达同一设备的并行打印。运行时会把同时到达的调用排队，但业务仍应逐条
  `await`，以便准确记录哪一条成功或失败。
- 大批次分段并持久化 `NextIndex`；页面关闭、断连或写失败后从失败位置人工确认再恢复。
- `setPrinterNum(n)` 只适合同一缓冲区重复发送，不适合每张内容不同的批次。
- 业务落库与蓝牙打印不是原子事务。用稳定业务单号支持受控重打，不重复执行业务写入。

## 兼容性与当前限制

- Web Bluetooth 仅把四个常见服务 UUID 传入 `optionalServices`：`18f0`、`ff00`、
  `49535343-fe7d-4ae5-8fa9-9fafd205e455`、`e7810a71-73ae-499d-8c15-faa9aef0c3f2`。
  当前没有公开的自定义服务配置，并选择枚举到的第一个可写特征；其它型号可能需要扩展源码。
  CC4 固件若只开放 SPP 或使用其它私有 UUID，Web 端不可连接；Android 5+App 使用已配对 SPP，
  或先取得厂家准确 BLE UUID 再扩展源码，禁止猜 UUID。
- `prepareSend` 默认每包 20 字节；Android 5+ 的佳博 GP-M322 在特征支持时优先使用同一
  `plus.bluetooth` 连接的 `writeNoResponse`，逐包 await API 回调并保留约 8ms 的 GATT 保护窗口；
  即使协商出 180 字节有效载荷也只采用 100 字节稳定档。不支持无响应写时保留 `write` 兼容路径。其它型号、iOS、Web 与 SPP
  保留约 20ms，同一缓冲区多份间约 100ms。5+ BLE 的连接、服务发现和写入统一使用 `plus.bluetooth`，
  禁止用 `uni.writeBLECharacteristicValue` 写入由 `plus.bluetooth` 建立的连接。
  这是写节奏，不是物理走纸确认。5+ BLE 同时受本次连接的 `maxWriteBytes` 和更保守的
  `recommendedPacketSize` 限制；未知 MTU 为 20。
- 5+ 佳博在服务发现后协商 MTU，最多等待 1.5 秒，`getConnectionState()` 返回 `mtu`、
  `maxWriteBytes`、`recommendedPacketSize`（20/100）、`writeType`、`packetIntervalMs`。
  必须读取真实回调的 `mtu`；空成功、失败、超时或缺 API 不可按请求值升档，迟到回调不得提升能力，
  断开时清除能力。自定义微服务需按这些字段判断，不能只允许 `engine === 'web'`。
  `microi.app` 在线壳更新远程前端即可获取本修复；WebView 缺少 Web Bluetooth 时不增加无效切换选项。
- 当前分包公式使用 `Math.ceil(length / packetSize)`，长度恰好整除时不会产生 0 字节末包；
  空数据、非法包长和非法份数会直接抛错。合法数值仍须按目标打印机实测。
- TSC 与 ESC 文本使用仓库内置 `encoding.js` + `encoding-indexes.js` 转为 GB18030，运行时
  不请求网络。编码成功不等于打印机字体、代码页和固件支持全部字符；Emoji 等仍需实机验证。
- `setBitmap` 接受 ImageData 风格 `{ width, height, data }` RGBA 数据。当前黑白转换较简单，
  大图可能产生大缓冲区；先缩放、二值化并用小图测试。
- `V8.Print` 使用应用级共享发送队列，跨 V8 上下文不会再并发覆盖 `currentTime`、`looptime`、
  `lastData` 等共享状态。队列只保证写入顺序，不提供打印机 ACK、业务事务或自动重打语义。
- CC4 遇到不支持的方法、复制后丢失元数据、缺少或重复 `setPagePrint()` 时应零写入失败；不要
  在业务层捕获后把原 TSPL 盲目重发给 CC4。

## 安全边界

- TSC 的 `setText`、`setQR`、`setBarCode` 和 `addCommand` 会拼协议文本。移除引号、换行、
  NUL/控制字符并限制长度；`addCommand` 只接受固定、受审查的命令。
- 蓝牙设备名称、ID 和服务特征均是外部输入。不要拼入 `innerHTML`，展示时做文本转义；不要
  记录或上传完整 `BLEInformation`，以免泄露终端指纹。
- 金额、数量、坐标、纸张尺寸、包长和份数先做类型/范围校验，避免无限循环或超大缓冲区。
- 打印内容含个人信息、票据或密钥时，不写控制台、系统日志或异常上报正文。
- 浏览器权限拒绝、用户取消、GATT 断开、找不到服务/特征和写包失败都必须可理解地提示。
- 搜索不到设备的头号原因是残留 GATT 连接：BLE 外设在已连接期间停止广播，上一次连接被并发流程打断或 App 重启后系统仍保留旧链路时，打印机“没在用”但也搜不到。平台在开始发现前会先 `getConnectedBluetoothDevices` 并关闭应用未持有的连接，被断开/重连事件接管时也会显式关闭自己建立的链路；排查顺序据此固定为“清理残留连接 → 打印机重新上电 → 系统蓝牙忽略设备 → 再搜索”，不要先去改包长、MTU 或指令集。

## 实机验收

至少记录：

1. GP-M322 与 CC4 的固件、纸张规格、服务/写特征 UUID 或 SPP、指令集。
2. 5+App 或浏览器版本；首次授权/配对、自动/手工选型、再次连接、主动断开、页面刷新和断线重连。
3. 中文、数字、特殊字符、二维码、条码、长文本、图片和边界金额。
4. 默认 20 字节与目标包长；同时覆盖“长度恰好整除包长”。
5. 连续 20 张严格串行发送，无乱序、丢包、重复或任务状态互相污染。
6. 中途关机、缺纸、离开范围、权限撤销后的失败位置与恢复行为。
7. 两种设备交替连接，证明 GP 原 TSPL 不变、CC4 收到 CPCL/ESC-POS；CC4 分别记录 BLE 与 SPP。
8. 页面只确认“数据已发送”；若业务要求确认物理结果，另接状态回读或人工确认。
