Files
desktop-plugin/lib/print/pavo/pavo_printer.dart
T
dzc 0fea65018b chore: Pavo 枚举链路加 [pavo-diag] 诊断日志(Windows 枚举空问题定位)
用户 Windows 实测:驱动装好后(cs2xxhcd.inf 入库、安装器触发过真实打印、
打印机本体正常——同一台机器 mac 全通)枚举反而返回空("打印机未连接"),
且装驱动前能枚举到(status=0x80)——枚举走的设备通道在驱动绑定前后变化。

对枚举内部加 stdout 诊断:两段式返回码、原始缓冲前 96 字节 hex、
解码与拆分结果(flutter run 控制台可见),下次运行即可区分
"DLL 层失败(ret/size)"还是"解码拆分层问题"。
2026-09-21 17:18:02 +08:00

289 lines
11 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 编码。
static String decodeAuto(Uint8List buf) {
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();
}
}