Files
desktop-plugin/lib/print/pavo/pavo_printer.dart
T
dzc 62909609d1 fix(windows): 枚举解码误判 UTF-16 为 UTF-8 导致设备路径碎片化——0x80 全假象
用户 Windows 无驱动实测日志破案:PavoApiW_x64 枚举成功(size=256),缓冲是
UTF-16LE 的裸 USB 设备路径(\\?\usb#vid_0d16&pid_0309#serial#{guid}),
但 printableRatio 把 \0 计为可打印字符,UTF-8 误判通过 → 路径被 \0 拆成
单字符碎片(first='\')→ CheckPrinterStatus/PrintOneCard 全部 0x80——
此前所有"离线"均为碎片名所致,设备与通信从未真正尝试过。

结论反转:**Windows 免驱动成立且必须免驱动**——PavoApi 直连裸 USB 设备
(GUID_DEVINTERFACE_USB_DEVICE);装 HiTi 驱动反而接管 USB 接口导致枚举为空
(用户实测:装驱动前枚举到、装后 size=0、卸载后又枚举到)。

修复:
- decodeAuto 增加 UTF-16LE 特征判定(奇数位字节全零)优先于比例启发;
  新增守护测试(设备路径形态整串不碎片化)
- 向导 Windows 分支反转:不再提供驱动安装(mac only),文案明确
  "SDK 直连无需驱动;已装 HiTi 驱动请卸载(驱动占用 USB 接口导致无法直连)"
2026-09-21 17:49:15 +08:00

311 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:convert';
import 'dart:ffi';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import '../../image/card_image.dart';
import 'pavo_ffi.dart';
// Windows 枚举/直打链路的 [pavo-diag] 诊断日志(stdout,flutter run 控制台可见)
// ignore_for_file: avoid_print
/// 携带 HiTi 状态码的打印异常(错误码进任务 status,文案进 statusText)。
class HitiException implements Exception {
HitiException(this.statusCode, this.message);
final int statusCode;
final String message;
@override
String toString() => message;
}
/// HiTi CS-2xx 打印机高层操作:枚举、状态、设备信息、单卡直打。
///
/// PavoApi 直打链路(对齐 SDK DemoCSharp buttonDirectPrint 与 C# Hiti/PavoPrinter.cs):
/// PAVO_PrintOneCard(打印机名, 分层 BITMAP, 默认加热能量, 默认设置) → 轮询 CheckPrinterStatus 直到非忙碌。
/// 全部为同步 ffi 调用,必须在独立 isolate 中运行(见 PavoPrintBackend)。
class PavoPrinter {
/// 枚举 USB 连接的证卡打印机名。SDK 缓冲可能是 UTF-8 或 UTF-16,两种都尝试解析。
///
/// 含 [pavo-diag] 诊断日志(stdout):Windows 枚举问题排查用,
/// 输出两段式返回码/原始缓冲 hex/解码与拆分结果,flutter run 控制台可见。
static List<String> enumUsbPrinters(PavoApi api) {
final sizePtr = calloc<Uint32>();
try {
var ret = api.enumUsbCardPrinters(nullptr, sizePtr);
print('[pavo-diag] enum#1 ret=0x${ret.toRadixString(16)} size=${sizePtr.value}');
if (ret != 0 || sizePtr.value == 0) {
return const [];
}
final buf = calloc<Uint8>(sizePtr.value);
try {
final size = sizePtr.value;
sizePtr.value = size;
ret = api.enumUsbCardPrinters(buf, sizePtr);
final bytes = buf.asTypedList(size);
final hex = bytes
.take(96)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join(' ');
print('[pavo-diag] enum#2 ret=0x${ret.toRadixString(16)} size=$size raw[$hex]');
if (ret != 0) {
return const [];
}
final decoded = decodeAuto(bytes);
final list = decoded
.split(RegExp('[\x00\n\r,]'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
print('[pavo-diag] decoded="$decoded" => $list');
return list;
} finally {
calloc.free(buf);
}
} finally {
calloc.free(sizePtr);
}
}
/// 按可打印字符占比自动判断 UTF-8 / UTF-16 编码。
///
/// 实测(Win11 无驱动裸机):枚举缓冲返回 UTF-16LE 的设备路径
/// (\\?\usb#vid_xxxx&pid_xxxx#serial#{guid})。ASCII 的 UTF-16LE 呈现
/// "奇数位字节全零"特征——printableRatio 会把 \0 算作可打印,导致 UTF-8
/// 误判通过、路径被 \0 拆成单字符碎片(后续 API 拿 '\' 单字符全部 0x80)。
/// 故先用奇数位全零特征做 UTF-16LE 判定,再回退比例启发。
static String decodeAuto(Uint8List buf) {
if (buf.length >= 8) {
var oddAllZero = true;
for (var i = 1; i < buf.length; i += 2) {
if (buf[i] != 0) {
oddAllZero = false;
break;
}
}
if (oddAllZero) {
final codes = <int>[];
for (var i = 0; i + 1 < buf.length; i += 2) {
codes.add(buf[i] | (buf[i + 1] << 8));
}
return String.fromCharCodes(codes);
}
}
final utf8Text = utf8.decode(buf, allowMalformed: true);
if (printableRatio(utf8Text) >= 0.9) {
return utf8Text;
}
final codes = <int>[];
for (var i = 0; i + 1 < buf.length; i += 2) {
codes.add(buf[i] | (buf[i + 1] << 8));
}
final utf16Text = String.fromCharCodes(codes);
return printableRatio(utf16Text) >= 0.9 ? utf16Text : utf8Text;
}
static double printableRatio(String s) {
if (s.isEmpty) return 0;
var ok = 0;
for (final c in s.codeUnits) {
if ((c >= 0x20 && c <= 0x7e) || (c >= 0x4e00 && c <= 0x9fff) || c == 0) ok++;
}
return ok / s.length;
}
/// 查询设备状态码,0 = 就绪;API 调用失败时返回的就是错误码本身。
static int getStatus(PavoApi api, Pointer<Utf16> printer) {
final statusPtr = calloc<Uint32>();
try {
final ret = api.checkPrinterStatus(printer, statusPtr);
return ret != 0 ? ret : statusPtr.value;
} finally {
calloc.free(statusPtr);
}
}
/// 读字符串型设备信息(序列号/型号/固件,UTF-16LE)。
static String getDeviceInfoText(PavoApi api, Pointer<Utf16> printer, int infoType) {
final buf = calloc<Uint8>(512);
final lenPtr = calloc<Uint32>()..value = 512;
try {
final ret = api.getDeviceInfo(printer, infoType, buf, lenPtr);
if (ret != 0 || lenPtr.value == 0) {
return '';
}
final n = lenPtr.value;
final codes = <int>[];
for (var i = 0; i + 1 < n; i += 2) {
final c = buf[i] | (buf[i + 1] << 8);
if (c != 0) codes.add(c);
}
return String.fromCharCodes(codes);
} finally {
calloc.free(buf);
calloc.free(lenPtr);
}
}
/// 色带信息:(类型码, 剩余张数)。
static (int, int) getRibbonInfo(PavoApi api, Pointer<Utf16> printer) {
final buf = calloc<Uint8>(64);
final lenPtr = calloc<Uint32>()..value = 64;
try {
final ret = api.getDeviceInfo(printer, PavoApi.devinfoRibbonInfo, buf, lenPtr);
if (ret != 0 || lenPtr.value < 8) {
return (PavoApi.ribbonMissing, 0);
}
final v = buf.asTypedList(8);
final type = v[0] | (v[1] << 8) | (v[2] << 16) | (v[3] << 24);
final count = v[4] | (v[5] << 8) | (v[6] << 16) | (v[7] << 24);
return (type, count);
} finally {
calloc.free(buf);
calloc.free(lenPtr);
}
}
static String ribbonTypeName(int type) => switch (type) {
PavoApi.ribbonTypeYmcko => 'YMCKO',
PavoApi.ribbonTypeK => 'Resin K',
PavoApi.ribbonTypeKo => 'KO',
PavoApi.ribbonTypeYmckok => 'YMCKOK',
PavoApi.ribbonTypeHalfYmcko => '1/2 YMCKO',
PavoApi.ribbonTypeYmckfo => 'YMCKFO',
PavoApi.ribbonMissing => '未安装',
_ => '未知($type)',
};
/// 常见状态码中文文案,与前端翻译表一致。
static String statusTextOf(int status) => switch (status) {
0 => '就绪',
PavoApi.dsPrinting => '打印机正在打印',
PavoApi.dsProcessingData => '正在处理打印数据',
PavoApi.dsSendingData => '正在向打印机发送数据',
PavoApi.dsWriteFail => '向打印机发送命令失败',
PavoApi.dsReadFail => '打印机响应失败',
PavoApi.dsOffline => '打印机已断开连接或已关机',
PavoApi.dsCardJam1 => '卡片通道卡纸,请开盖取出卡片',
PavoApi.dsCardJam2 => '翻卡器处卡纸,请开盖取出卡片',
PavoApi.dsCardJam3 => '出卡槽卡纸,请取出卡片',
PavoApi.dsCmdSeqError => '命令序列错误,请重启打印机',
PavoApi.dsCoverOpen => '打印机盖未关闭',
PavoApi.dsCardOut => '缺卡或进卡失败,请补充卡片',
PavoApi.dsCardLow => '卡量不足,请及时补充卡片',
PavoApi.dsCardThickWrong => '卡片厚度选择器未正确安装',
PavoApi.dsBusy => '设备忙碌,请稍候',
PavoApi.dsRibbonMissing => '色带未安装',
PavoApi.dsOutOfRibbon => '色带已用尽,请更换色带',
PavoApi.dsUnsupportRibbon => '不支持的色带类型',
PavoApi.dsUnknownRibbon => '无法识别色带',
PavoApi.dsNoFlipperModule => '翻卡模块未安装',
PavoApi.locked => '打印机已被锁定,请先解锁',
_ => '设备状态异常 0x${status.toRadixString(16).padLeft(8, '0').toUpperCase()}',
};
/// 打印一张卡。frontBgr 为彩色层(BGR),frontK 可选(树脂黑文字层)。
/// isLandscape 由正面图纵横比得出:横向 2 / 纵向 1(SDK byOrientation)。
/// copies 大于 1 时循环提交(直打接口无份数参数)。
static Future<void> printOneCard(
PavoApi api,
Pointer<Utf16> printer, {
required Uint8List frontBgr,
Uint8List? frontK,
Uint8List? backBgr,
Uint8List? backK,
required bool isLandscape,
required int copies,
}) async {
final width = isLandscape ? CardImage.landscapeWidth : CardImage.landscapeHeight;
final height = isLandscape ? CardImage.landscapeHeight : CardImage.landscapeWidth;
final n = copies < 1 ? 1 : copies;
for (var i = 0; i < n; i++) {
final holder = LayerHolder();
final para = calloc<HitiCardPrintParameter>();
final heat = calloc<HitiHeatingEnergy>();
final settings = calloc<HitiSettings>();
try {
para.ref
..dwSize = sizeOf<HitiCardPrintParameter>()
..byOrientation = isLandscape ? 2 : 1
..byCardThickness = 1 // 0.5mm 标准 PVC 卡
..lpFrontBGR = holder.pin(frontBgr, 24, width, height)
..lpFrontK = frontK == null ? nullptr : holder.pin(frontK, 8, width, height)
..lpBackBGR = backBgr == null ? nullptr : holder.pin(backBgr, 24, width, height)
..lpBackK = backK == null ? nullptr : holder.pin(backK, 8, width, height);
// heat/settings 由 calloc 零初始化,等价 C# 全 0(驱动默认能量/设置)
final ret = api.printOneCard(printer, para, heat, settings);
if (ret != 0) {
throw HitiException(ret, 'PAVO_PrintOneCard 返回错误 0x${_hex8(ret)}');
}
// 对齐 C# Demo(using 块内轮询):等固件处理数据后轮询,直到打印机脱离忙碌/打印态;
// 像素内存在轮询完成后才释放(finally)
await Future<void>.delayed(const Duration(seconds: 3));
final status = await waitIdle(api, printer);
if (status != 0) {
throw HitiException(status, '打印中断,设备状态 0x${_hex8(status)}');
}
} finally {
holder.dispose();
calloc.free(para);
calloc.free(heat);
calloc.free(settings);
}
}
}
/// 轮询直到非 BUSY/PRINTING/PROCESSING/SENDING;超时 3 分钟抛错。
static Future<int> waitIdle(PavoApi api, Pointer<Utf16> printer) async {
final deadline = DateTime.now().add(const Duration(minutes: 3));
while (DateTime.now().isBefore(deadline)) {
final status = getStatus(api, printer);
if (status != PavoApi.dsBusy &&
status != PavoApi.dsPrinting &&
status != PavoApi.dsProcessingData &&
status != PavoApi.dsSendingData) {
return status;
}
await Future<void>.delayed(const Duration(seconds: 1));
}
throw TimeoutException('等待打印机完成超时(3 分钟)');
}
static String _hex8(int v) => v.toRadixString(16).padLeft(8, '0').toUpperCase();
}
/// 打印期间持有非托管像素内存,dispose 统一释放(对齐 C# LayerHolder)。
class LayerHolder {
final _allocations = <Pointer>[];
/// 把像素缓冲包成 GDI BITMAP 结构并拷贝像素到非托管内存,返回 BITMAP 指针。
Pointer<Uint8> pin(Uint8List pixels, int bitsPerPixel, int width, int height) {
final widthBytes = width * (bitsPerPixel ~/ 8);
final bits = malloc<Uint8>(pixels.length);
bits.asTypedList(pixels.length).setAll(0, pixels);
final bmp = malloc<GdiBitmap>();
bmp.ref
..bmType = 0
..bmWidth = width
..bmHeight = height
..bmWidthBytes = widthBytes
..bmPlanes = 1
..bmBitsPixel = bitsPerPixel
..bmBits = bits;
_allocations
..add(bits)
..add(bmp);
return bmp.cast();
}
void dispose() {
for (final p in _allocations) {
malloc.free(p);
}
_allocations.clear();
}
}