feat: Windows PavoApi ffi 直打后端(Phase 3 代码完成,待实机验收)
- 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 个全过
This commit is contained in:
1 parent
689a07ca44
commit
0a78ff1b58
28 files changed
+1874
-5
No files matched your search
@@ -0,0 +1,90 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:image/image.dart';
|
||||
|
||||
/// PNG data URL 解析与像素层转换(Windows PavoApi 直打用)。
|
||||
///
|
||||
/// 卡面为 CR-80 @300dpi:横向 1014×642 / 纵向 642×1014,按源图纵横比自动选择;
|
||||
/// 透明区域铺白,避免黑底(对齐 C# 版 Hiti/CardImage.cs)。
|
||||
class CardImage {
|
||||
CardImage._(this._canvas, this.width, this.height);
|
||||
|
||||
static const int landscapeWidth = 1014;
|
||||
static const int landscapeHeight = 642;
|
||||
|
||||
final Image _canvas;
|
||||
final int width;
|
||||
final int height;
|
||||
|
||||
/// 宽 ≥ 高 = 横向卡面
|
||||
bool get isLandscape => width >= height;
|
||||
|
||||
/// 解析 data:image/png;base64,…(或其他 data URL 形式),缩放到卡面画布并铺白底。
|
||||
static CardImage fromDataUrl(String dataUrl) {
|
||||
if (dataUrl.trim().isEmpty) {
|
||||
throw ArgumentError('卡面图为空');
|
||||
}
|
||||
final comma = dataUrl.indexOf(',');
|
||||
final base64Part = comma >= 0 ? dataUrl.substring(comma + 1) : dataUrl;
|
||||
final bytes = base64.decode(base64Part.replaceAll(RegExp(r'\s'), ''));
|
||||
final raw = decodeImage(bytes);
|
||||
if (raw == null) {
|
||||
throw ArgumentError('卡面图解码失败(不支持的图片格式)');
|
||||
}
|
||||
|
||||
final landscape = raw.width >= raw.height;
|
||||
final width = landscape ? landscapeWidth : landscapeHeight;
|
||||
final height = landscape ? landscapeHeight : landscapeWidth;
|
||||
|
||||
final resized = copyResize(raw, width: width, height: height);
|
||||
final canvas = Image(width: width, height: height);
|
||||
// 手动 alpha 混合铺白底(与 C# 版逐像素语义一致):
|
||||
// image 包的 compositeImage 对全透明 src 像素不是 src-over 语义,透明处会漏黑
|
||||
for (var y = 0; y < height; y++) {
|
||||
for (var x = 0; x < width; x++) {
|
||||
final p = resized.getPixel(x, y);
|
||||
final a = p.a.toInt() / 255.0;
|
||||
canvas.setPixelRgba(
|
||||
x,
|
||||
y,
|
||||
(255 * (1 - a) + p.r * a).round().clamp(0, 255),
|
||||
(255 * (1 - a) + p.g * a).round().clamp(0, 255),
|
||||
(255 * (1 - a) + p.b * a).round().clamp(0, 255),
|
||||
255,
|
||||
);
|
||||
}
|
||||
}
|
||||
return CardImage._(canvas, width, height);
|
||||
}
|
||||
|
||||
/// 转 BGR 24bit 紧凑像素(无行对齐填充,PavoApi BITMAP 用)。
|
||||
Uint8List toBgr24() {
|
||||
final packed = Uint8List(width * 3 * height);
|
||||
for (var y = 0; y < height; y++) {
|
||||
var offset = y * width * 3;
|
||||
for (var x = 0; x < width; x++) {
|
||||
final p = _canvas.getPixel(x, y);
|
||||
packed[offset] = p.b.toInt() & 0xff;
|
||||
packed[offset + 1] = p.g.toInt() & 0xff;
|
||||
packed[offset + 2] = p.r.toInt() & 0xff;
|
||||
offset += 3;
|
||||
}
|
||||
}
|
||||
return packed;
|
||||
}
|
||||
|
||||
/// 转 8bit 灰度(无行对齐)。树脂 K 层像素语义:0x00 = 转印黑色,
|
||||
/// 因此前端 K 层 PNG 为白底黑字,灰度值可直接使用。
|
||||
Uint8List toGray8() {
|
||||
final gray = Uint8List(width * height);
|
||||
for (var y = 0; y < height; y++) {
|
||||
var offset = y * width;
|
||||
for (var x = 0; x < width; x++) {
|
||||
final p = _canvas.getPixel(x, y);
|
||||
gray[offset + x] = (p.r.toInt() * 299 + p.g.toInt() * 587 + p.b.toInt() * 114) ~/ 1000 & 0xff;
|
||||
}
|
||||
}
|
||||
return gray;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
|
||||
import '../server/protocol.dart';
|
||||
import 'cups_backend.dart';
|
||||
import 'pavo/pavo_backend.dart';
|
||||
|
||||
/// 打印后端抽象:Windows 走 HiTi PavoApi 直打(分层 BITMAP);macOS 走系统 CUPS 队列。
|
||||
abstract class PrintBackend {
|
||||
@@ -17,10 +18,10 @@ abstract class PrintBackend {
|
||||
String osBackendName() => Platform.isWindows ? 'pavo' : 'cups';
|
||||
|
||||
/// 创建当前操作系统的真实打印后端(非 mock)。
|
||||
/// PavoPrintBackend 构造不加载 DLL(延迟到实际调用的子 isolate 内),mac 上亦可安全实例化/分析。
|
||||
PrintBackend createOsBackend({void Function(String line)? onLog}) {
|
||||
if (Platform.isWindows) {
|
||||
// Phase 3:PavoApi ffi 直打(待 Windows 实机联调)
|
||||
throw UnsupportedError('Windows PavoApi 后端尚未实现(Phase 3)');
|
||||
return PavoPrintBackend(onLog: onLog);
|
||||
}
|
||||
return CupsPrintBackend(onLog: onLog);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import 'dart:isolate';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
|
||||
import '../../image/card_image.dart';
|
||||
import '../../server/protocol.dart';
|
||||
import '../backend.dart';
|
||||
import '../cups_backend.dart' show noopLog;
|
||||
import 'pavo_ffi.dart';
|
||||
import 'pavo_printer.dart';
|
||||
|
||||
/// Windows 打印后端:HiTi PavoApi 直打(BGR/K 分层 BITMAP)。
|
||||
///
|
||||
/// 所有 ffi 调用(含像素转换的 CPU 密集计算)都放进 `Isolate.run`:
|
||||
/// PavoApi 的同步调用最长可阻塞数秒(发送 2MB 像素)、打印轮询最长 3 分钟,
|
||||
/// 放独立 isolate 保证主 isolate 的 HTTP 服务(任务状态轮询)不被卡住。
|
||||
/// DLL 与非托管指针都不跨 isolate(闭包内自建自释放)。
|
||||
class PavoPrintBackend implements PrintBackend {
|
||||
PavoPrintBackend({void Function(String line)? onLog}) : _log = onLog ?? noopLog;
|
||||
|
||||
final void Function(String line) _log;
|
||||
|
||||
@override
|
||||
String get name => 'pavo';
|
||||
|
||||
@override
|
||||
Future<PrinterStatus> getStatus() => Isolate.run(_statusSync);
|
||||
|
||||
PrinterStatus _statusSync() {
|
||||
try {
|
||||
final api = PavoApi();
|
||||
final printers = PavoPrinter.enumUsbPrinters(api);
|
||||
if (printers.isEmpty) {
|
||||
return PrinterStatus.offline('打印机未连接');
|
||||
}
|
||||
final printer = printers.first.toNativeUtf16();
|
||||
try {
|
||||
final status = PavoPrinter.getStatus(api, printer);
|
||||
final (ribbonType, remainCount) = PavoPrinter.getRibbonInfo(api, printer);
|
||||
final dto = PrinterStatus(
|
||||
online: true,
|
||||
model: PavoPrinter.getDeviceInfoText(api, printer, PavoApi.devinfoModelName),
|
||||
firmware: PavoPrinter.getDeviceInfoText(api, printer, PavoApi.devinfoFirmwareVersion),
|
||||
connection: 'USB',
|
||||
ribbon: RibbonInfo(
|
||||
type: PavoPrinter.ribbonTypeName(ribbonType),
|
||||
// 设备返回剩余张数,按常见 100 张装 YMCKO 色带折算百分比
|
||||
remain: ribbonType == PavoApi.ribbonMissing ? -1 : (remainCount > 100 ? 100 : remainCount),
|
||||
remainCount: remainCount,
|
||||
),
|
||||
status: status,
|
||||
statusText: PavoPrinter.statusTextOf(status),
|
||||
);
|
||||
if (status == PavoApi.dsCardLow || status == PavoApi.dsCardOut) {
|
||||
dto.cardLow = true;
|
||||
}
|
||||
return dto;
|
||||
} finally {
|
||||
calloc.free(printer);
|
||||
}
|
||||
} catch (e) {
|
||||
return PrinterStatus.offline('状态读取失败:$e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> printOneCard(PrintRequest request) {
|
||||
_log('Pavo 直打任务开始(份数 ${request.copies},后端在子 isolate 执行)');
|
||||
final front = request.front!;
|
||||
final back = request.back;
|
||||
// 只把 data URL 字符串与份数传进 isolate;图层转换与 ffi 调用全部在子 isolate 内完成
|
||||
final frontColor = front.color;
|
||||
final frontK = front.k;
|
||||
final backColor = back?.color;
|
||||
final backK = back?.k;
|
||||
final copies = request.copies;
|
||||
return Isolate.run(() async {
|
||||
final api = PavoApi();
|
||||
final printers = PavoPrinter.enumUsbPrinters(api);
|
||||
if (printers.isEmpty) {
|
||||
throw StateError('未找到 USB 连接的 HiTi 证卡打印机');
|
||||
}
|
||||
final printer = printers.first.toNativeUtf16();
|
||||
try {
|
||||
final frontImg = CardImage.fromDataUrl(frontColor);
|
||||
final frontBgr = frontImg.toBgr24();
|
||||
final Uint8List? frontKGray;
|
||||
if (frontK != null && frontK.isNotEmpty) {
|
||||
frontKGray = CardImage.fromDataUrl(frontK).toGray8();
|
||||
} else {
|
||||
frontKGray = null;
|
||||
}
|
||||
final Uint8List? backBgr;
|
||||
final Uint8List? backKGray;
|
||||
if (backColor != null && backColor.isNotEmpty) {
|
||||
final backImg = CardImage.fromDataUrl(backColor);
|
||||
backBgr = backImg.toBgr24();
|
||||
backKGray =
|
||||
backK != null && backK.isNotEmpty ? CardImage.fromDataUrl(backK).toGray8() : null;
|
||||
} else {
|
||||
backBgr = null;
|
||||
backKGray = null;
|
||||
}
|
||||
|
||||
await PavoPrinter.printOneCard(
|
||||
api,
|
||||
printer,
|
||||
frontBgr: frontBgr,
|
||||
frontK: frontKGray,
|
||||
backBgr: backBgr,
|
||||
backK: backKGray,
|
||||
isLandscape: frontImg.isLandscape,
|
||||
copies: copies,
|
||||
);
|
||||
} finally {
|
||||
calloc.free(printer);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
import 'dart:ffi';
|
||||
|
||||
import 'package:ffi/ffi.dart';
|
||||
|
||||
/// HiTi CS-2xx PavoApi 的 dart:ffi 绑定。
|
||||
///
|
||||
/// 依据 SDK Include/PavoApi.h(PrinterCardSDK_1_11_25)与 C# 版 Hiti/PavoApi.cs 翻译;
|
||||
/// 使用宽字符 DLL `PavoApiW_x64.dll`。C# CharSet.Unicode 的导出名回退行为
|
||||
/// (先找 PAVO_xxxW 再回退 PAVO_xxx)在此用 try-catch 复刻。
|
||||
/// 返回值约定:0 = 成功,非 0 = 错误码(ds* 状态码常量)。
|
||||
///
|
||||
/// 所有导出函数只允许在 Windows 上实际调用(DLL 加载失败即抛错);
|
||||
/// 结构体定义本身跨平台可编译,便于 mac 上 analyze 与单测。
|
||||
class PavoApi {
|
||||
PavoApi() : _lib = DynamicLibrary.open(_dllName) {
|
||||
_enumUsb = _tryEnum('PAVO_EnumUSBCardPrintersW') ?? _tryEnum('PAVO_EnumUSBCardPrinters')!;
|
||||
_checkStatus =
|
||||
_tryCheckStatus('PAVO_CheckPrinterStatusW') ?? _tryCheckStatus('PAVO_CheckPrinterStatus')!;
|
||||
_getDeviceInfo =
|
||||
_tryGetDeviceInfo('PAVO_GetDeviceInfoW') ?? _tryGetDeviceInfo('PAVO_GetDeviceInfo')!;
|
||||
_printOneCard = _tryPrintOneCard('PAVO_PrintOneCardW') ?? _tryPrintOneCard('PAVO_PrintOneCard')!;
|
||||
}
|
||||
|
||||
static const String _dllName = 'PavoApiW_x64.dll';
|
||||
|
||||
// ---------------- 状态码(PAVO_DS_*,CheckPrinterStatus 输出 / 任务错误码) ----------------
|
||||
static const int dsBusy = 0x00080000;
|
||||
static const int dsOffline = 0x00000080;
|
||||
static const int dsPrinting = 0x00000002;
|
||||
static const int dsProcessingData = 0x00000005;
|
||||
static const int dsSendingData = 0x00000006;
|
||||
static const int dsCardJam1 = 0x00030000;
|
||||
static const int dsCardJam2 = 0x00040000;
|
||||
static const int dsCardJam3 = 0x00050000;
|
||||
static const int dsCmdSeqError = 0x000301FE;
|
||||
static const int dsPrintFail1 = 0x00030002;
|
||||
static const int dsPrintFail2 = 0x00030003;
|
||||
static const int dsCoverOpen = 0x00050001;
|
||||
static const int dsCardOut = 0x00008000;
|
||||
static const int dsCardLow = 0x00008001;
|
||||
static const int dsCardThickWrong = 0x00008010;
|
||||
static const int dsRibbonMissing = 0x00080004;
|
||||
static const int dsOutOfRibbon = 0x00080103;
|
||||
static const int dsUnsupportRibbon = 0x000806FE;
|
||||
static const int dsUnknownRibbon = 0x000808FE;
|
||||
static const int dsNoFlipperModule = 0x1100000D;
|
||||
static const int dsWriteFail = 0x0000001F;
|
||||
static const int dsReadFail = 0x0000002F;
|
||||
static const int locked = 0x00007540;
|
||||
|
||||
// ---------------- GetDeviceInfo 信息类型 ----------------
|
||||
static const int devinfoMfgSerial = 1;
|
||||
static const int devinfoModelName = 2;
|
||||
static const int devinfoFirmwareVersion = 3;
|
||||
static const int devinfoRibbonInfo = 4;
|
||||
static const int devinfoPrintCount = 5;
|
||||
|
||||
// ---------------- 色带类型 ----------------
|
||||
static const int ribbonTypeYmcko = 0;
|
||||
static const int ribbonTypeK = 1;
|
||||
static const int ribbonTypeKo = 3;
|
||||
static const int ribbonTypeYmckok = 4;
|
||||
static const int ribbonTypeHalfYmcko = 5;
|
||||
static const int ribbonTypeYmckfo = 12;
|
||||
static const int ribbonMissing = 255;
|
||||
|
||||
final DynamicLibrary _lib;
|
||||
|
||||
// 枚举 USB 打印机:两段式(首传 nullptr 取所需大小,再传实际缓冲;缓冲内容编码见 PavoPrinter 解码)
|
||||
late final int Function(Pointer<Uint8>, Pointer<Uint32>) _enumUsb;
|
||||
late final int Function(Pointer<Utf16>, Pointer<Uint32>) _checkStatus;
|
||||
late final int Function(Pointer<Utf16>, int, Pointer<Uint8>, Pointer<Uint32>) _getDeviceInfo;
|
||||
late final int Function(
|
||||
Pointer<Utf16>, Pointer<HitiCardPrintParameter>, Pointer<HitiHeatingEnergy>, Pointer<HitiSettings>) _printOneCard;
|
||||
|
||||
// 宽字符导出名兼容:先找 xxxW,失败回退原名(对齐 C# CharSet.Unicode 行为)。
|
||||
int Function(Pointer<Uint8>, Pointer<Uint32>)? _tryEnum(String name) {
|
||||
try {
|
||||
return _lib.lookupFunction<Uint32 Function(Pointer<Uint8>, Pointer<Uint32>),
|
||||
int Function(Pointer<Uint8>, Pointer<Uint32>)>(name);
|
||||
} on ArgumentError {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
int Function(Pointer<Utf16>, Pointer<Uint32>)? _tryCheckStatus(String name) {
|
||||
try {
|
||||
return _lib.lookupFunction<Uint32 Function(Pointer<Utf16>, Pointer<Uint32>),
|
||||
int Function(Pointer<Utf16>, Pointer<Uint32>)>(name);
|
||||
} on ArgumentError {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
int Function(Pointer<Utf16>, int, Pointer<Uint8>, Pointer<Uint32>)? _tryGetDeviceInfo(String name) {
|
||||
try {
|
||||
return _lib.lookupFunction<Uint32 Function(Pointer<Utf16>, Uint32, Pointer<Uint8>, Pointer<Uint32>),
|
||||
int Function(Pointer<Utf16>, int, Pointer<Uint8>, Pointer<Uint32>)>(name);
|
||||
} on ArgumentError {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
int Function(Pointer<Utf16>, Pointer<HitiCardPrintParameter>, Pointer<HitiHeatingEnergy>,
|
||||
Pointer<HitiSettings>)? _tryPrintOneCard(String name) {
|
||||
try {
|
||||
return _lib.lookupFunction<
|
||||
Uint32 Function(Pointer<Utf16>, Pointer<HitiCardPrintParameter>,
|
||||
Pointer<HitiHeatingEnergy>, Pointer<HitiSettings>),
|
||||
int Function(Pointer<Utf16>, Pointer<HitiCardPrintParameter>, Pointer<HitiHeatingEnergy>,
|
||||
Pointer<HitiSettings>)>(name);
|
||||
} on ArgumentError {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 两段式枚举 USB 证卡打印机:buf 传 nullptr 时返回所需缓冲大小。
|
||||
int enumUsbCardPrinters(Pointer<Uint8> buf, Pointer<Uint32> size) => _enumUsb(buf, size);
|
||||
|
||||
/// 查询状态码(0=就绪);返回值非 0 时是 API 层错误码。
|
||||
int checkPrinterStatus(Pointer<Utf16> printer, Pointer<Uint32> status) => _checkStatus(printer, status);
|
||||
|
||||
/// 读设备信息(缓冲写 UTF-16LE;RIBBON_INFO 为 dword[0]=类型 dword[1]=剩余张数)。
|
||||
int getDeviceInfo(
|
||||
Pointer<Utf16> printer, int infoType, Pointer<Uint8> data, Pointer<Uint32> dataLen) =>
|
||||
_getDeviceInfo(printer, infoType, data, dataLen);
|
||||
|
||||
/// 单卡直打(分层 BITMAP + 默认加热能量 + 默认设置)。
|
||||
int printOneCard(
|
||||
Pointer<Utf16> printer,
|
||||
Pointer<HitiCardPrintParameter> jobPara,
|
||||
Pointer<HitiHeatingEnergy> heatEnergy,
|
||||
Pointer<HitiSettings> settings) =>
|
||||
_printOneCard(printer, jobPara, heatEnergy, settings);
|
||||
}
|
||||
|
||||
/// wingdi BITMAP:指向裸像素缓冲(x64 下 32 字节,Dart Struct 按 C ABI 自然对齐,布局一致)。
|
||||
final class GdiBitmap extends Struct {
|
||||
@Int32()
|
||||
external int bmType;
|
||||
@Int32()
|
||||
external int bmWidth;
|
||||
@Int32()
|
||||
external int bmHeight;
|
||||
@Uint32()
|
||||
external int bmWidthBytes;
|
||||
@Uint16()
|
||||
external int bmPlanes;
|
||||
@Uint16()
|
||||
external int bmBitsPixel;
|
||||
external Pointer<Uint8> bmBits;
|
||||
}
|
||||
|
||||
/// PAVO_PrintOneCard 参数。像素约定(头文件注释):
|
||||
/// BGR 层 24bit BGRBGR…;K 层 8bit,0x00 的像素由树脂 K 转印到卡面;O 层 8bit,0xFF 的像素转印覆膜。
|
||||
final class HitiCardPrintParameter extends Struct {
|
||||
@Uint32()
|
||||
external int dwSize;
|
||||
@Uint32()
|
||||
external int dwReserve1;
|
||||
@Uint32()
|
||||
external int dwReserve2;
|
||||
@Uint32()
|
||||
external int dwFlags;
|
||||
|
||||
/// 1=portrait 2=landscape
|
||||
@Uint8()
|
||||
external int byOrientation;
|
||||
|
||||
/// 0=0.3mm 1=0.5mm 2=0.8mm 3=1.0mm
|
||||
@Uint8()
|
||||
external int byCardThickness;
|
||||
|
||||
/// 0=白卡 1=透明卡
|
||||
@Uint8()
|
||||
external int byTransparentCard;
|
||||
@Uint8()
|
||||
external int byReserve4;
|
||||
@Uint8()
|
||||
external int byReserve5;
|
||||
@Uint8()
|
||||
external int byReserve6;
|
||||
@Uint8()
|
||||
external int byReserve7;
|
||||
@Uint8()
|
||||
external int byReserve8;
|
||||
|
||||
external Pointer<Uint8> lpFrontBGR;
|
||||
external Pointer<Uint8> lpFrontK;
|
||||
external Pointer<Uint8> lpFrontO;
|
||||
external Pointer<Uint8> lpBackBGR;
|
||||
external Pointer<Uint8> lpBackK;
|
||||
external Pointer<Uint8> lpBackO;
|
||||
external Pointer<Uint8> lpReserve1;
|
||||
external Pointer<Uint8> lpReserve2;
|
||||
}
|
||||
|
||||
/// 加热能量,全 0 表示用驱动默认值(x64 下 16 字节)。
|
||||
final class HitiHeatingEnergy extends Struct {
|
||||
@Array(16)
|
||||
external Array<Uint8> data;
|
||||
}
|
||||
|
||||
/// 打印附加设置,全 0 即可(x64 下 48 字节)。
|
||||
final class HitiSettings extends Struct {
|
||||
@Uint8()
|
||||
external int bSpecialO;
|
||||
@Array(15)
|
||||
external Array<Uint8> bReserved;
|
||||
@Array(8)
|
||||
external Array<Uint16> wReserved;
|
||||
@Array(4)
|
||||
external Array<Uint32> dwReserved;
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user