用户实测两个问题(先装好驱动的机器): 1. 双击 App 图标不一定能打开——根因 single_instance:唤醒端口 38210 被 残留占用(上次异常退出/TIME_WAIT)时,connect 无应答仍返回 false → main 直接 exit(0)。修复:端口被占但无应答判定为残留占用,照常启动 (仅唤醒功能降级,FileLog 留痕);有应答才作为第二实例退出。 2. 向导第三步点"完成"没反应——根因 _finish 只有 finally 没有 catch: Autostart/UrlScheme(reg 三连)/FirstRun 任一抛异常被静默吞,Navigator.pop 永不执行=对话框锁死。修复:每步独立 try-catch + 分步日志,成败都关闭 向导,失败汇总 SnackBar 提示"详见运行日志"。 本地日志文件系统(用户要求:仔细记录、可发回分析、定时删除): - lib/utils/file_log.dart:按天滚动 app-YYYYMMDD.log、单文件 5MB 轮转加 序号、启动清理 7 天前旧文件(按文件名日期判定);全同步追加写(低频 日志,保证异常退出前落盘);目录 mac ~/Library/Logs/zax-desktop-plugin/ win %APPDATA%\zax-desktop-plugin\logs\(不引第三方依赖) - AgentLog.write 挂文件输出(一处改动覆盖全部 22 个现有调用点); main.dart FileLog.init 提到最前,bootWarning/startError 全部落日志, 启动横幅记录平台/exe 路径/参数 - pavo isolate 的 [pavo-diag] 诊断日志传日志路径(纯数据,isolate 安全) 直接写文件——GUI 模式下 isolate stdout 本来就丢 - 插件页技术详情加"打开日志文件夹"按钮(Explorer/Finder 直达,用户打包 app-*.log 发回分析) 19 个测试全过、analyze 零问题。
320 lines
12 KiB
Dart
320 lines
12 KiB
Dart
import 'dart:async';
|
||
import 'dart:convert';
|
||
import 'dart:ffi';
|
||
import 'dart:typed_data';
|
||
|
||
import 'package:ffi/ffi.dart';
|
||
|
||
import '../../image/card_image.dart';
|
||
import '../../utils/file_log.dart';
|
||
import 'pavo_ffi.dart';
|
||
|
||
// Windows 枚举/直打链路的 [pavo-diag] 诊断日志(stdout + 本地日志文件)
|
||
// 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 {
|
||
/// 诊断日志:stdout(flutter run 控制台可见)+ 日志文件(isolate 内 stdout
|
||
/// 在 GUI 模式下丢失,文件是唯一可靠出口;路径由调用方以纯数据传入)。
|
||
static void diag(String? logPath, String line) {
|
||
print('[pavo-diag] $line');
|
||
if (logPath != null) {
|
||
FileLog.writeToPath(logPath, '[pavo-diag] $line');
|
||
}
|
||
}
|
||
|
||
/// 枚举 USB 连接的证卡打印机名。SDK 缓冲可能是 UTF-8 或 UTF-16,两种都尝试解析。
|
||
///
|
||
/// [logPath] 为本地日志文件路径(纯数据,isolate 安全);null 时仅 stdout。
|
||
static List<String> enumUsbPrinters(PavoApi api, {String? logPath}) {
|
||
final sizePtr = calloc<Uint32>();
|
||
try {
|
||
var ret = api.enumUsbCardPrinters(nullptr, sizePtr);
|
||
diag(logPath, '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(' ');
|
||
diag(logPath, '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();
|
||
diag(logPath, '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();
|
||
}
|
||
}
|