- lib/print/pavo/:PavoApiW_x64.dll 的 dart:ffi 绑定(结构体布局对齐 C#,W 后缀导出名回退兼容) - pavo_printer.dart:枚举(UTF-8/UTF-16 自动解码)/状态/色带信息/单卡直打/忙碌轮询(3 分钟超时)/LayerHolder 非托管内存管理 - pavo_backend.dart:全部 ffi 调用与像素转换跑在 Isolate.run,主 isolate HTTP 服务零阻塞 - lib/image/card_image.dart:PNG data URL → 纵横比选卡面 → 手动 alpha 铺白底 → BGR24/Gray8 紧凑像素(image 包 compositeImage 对全透明像素非 src-over 语义,改为逐像素混合对齐 C#) - windows/ 目录:从 Flutter SDK windows.tmpl 手动实例化(mac 上 flutter create 拒绝生成的绕过),窗口标题"饭卡打印助手" - windows/vendor/:收录 SDK 的 PavoApiW_x64.dll/PavoCardSDK_x64.dll/HeatTB3.dll/heatthcd.bin,CMake POST_BUILD 自动拷到 exe 旁 - backend.dart 工厂接通 Windows 分支;analyze 零问题、单测 12 个全过
273 lines
10 KiB
Dart
273 lines
10 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 'pavo_ffi.dart';
|
||
|
||
/// 携带 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,两种都尝试解析。
|
||
static List<String> enumUsbPrinters(PavoApi api) {
|
||
final sizePtr = calloc<Uint32>();
|
||
try {
|
||
var ret = api.enumUsbCardPrinters(nullptr, sizePtr);
|
||
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);
|
||
if (ret != 0) {
|
||
return const [];
|
||
}
|
||
return decodeAuto(buf.asTypedList(size))
|
||
.split(RegExp('[\x00\n\r,]'))
|
||
.map((s) => s.trim())
|
||
.where((s) => s.isNotEmpty)
|
||
.toList();
|
||
} 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();
|
||
}
|
||
}
|