Files
apskel-pos-flutter-v2/lib/infrastructure/printer/datasource/network_printer_client.dart
T

272 lines
8.2 KiB
Dart
Raw Normal View History

import 'dart:async';
import 'dart:developer';
import 'dart:io';
/// Kenapa sebuah cetakan jaringan dianggap gagal.
enum NetworkPrintFailure {
/// Socket tidak bisa dibuka — IP salah, printer mati, atau beda jaringan.
unreachable,
/// Printer menjawab bahwa kertasnya habis.
paperOut,
/// Printer menjawab bahwa cover-nya terbuka.
coverOpen,
/// Printer menjawab sedang offline / dalam kondisi error.
offline,
/// Koneksi terbuka tapi pengiriman data putus di tengah jalan.
writeFailed,
}
class NetworkPrintResult {
final NetworkPrintFailure? failure;
/// Printer membalas perintah status. Printer clone yang tidak mendukung
/// `DLE EOT` membuat ini `false`, dan pemeriksaan kondisi kertas dilewati.
final bool statusSupported;
/// Kertas menipis. Bukan kegagalan — cetakan tetap keluar, tapi layak
/// diberitahukan ke kasir.
final bool paperNearEnd;
const NetworkPrintResult({
this.failure,
this.statusSupported = false,
this.paperNearEnd = false,
});
bool get isSuccess => failure == null;
}
/// Mencetak ke printer thermal jaringan lewat raw socket port 9100.
///
/// Berbeda dari `flutter_esc_pos_network` yang hanya mengirim byte lalu menutup
/// koneksi, klien ini menanyakan kondisi printer dengan `DLE EOT` dan membaca
/// jawabannya. Tanpa itu, kertas habis atau cover terbuka tidak bisa dibedakan
/// dari cetakan yang berhasil — byte tetap diterima socket dan semuanya tampak
/// sukses.
class NetworkPrinterClient {
static const _logName = 'NetworkPrinterClient';
/// Perintah status real-time ESC/POS: `DLE EOT n`.
static const _statusPrinter = [0x10, 0x04, 1];
static const _statusOffline = [0x10, 0x04, 2];
static const _statusPaper = [0x10, 0x04, 4];
/// Jeda menunggu balasan status. Sengaja pendek: printer yang tidak
/// mendukung `DLE EOT` tidak akan pernah menjawab, dan kita tidak boleh
/// menahan kasir hanya untuk menunggu jawaban yang tidak akan datang.
static const _statusTimeout = Duration(milliseconds: 800);
/// Host yang terbukti tidak menjawab `DLE EOT`. Tanpa catatan ini, printer
/// clone membuat setiap cetakan membayar timeout di atas — mahal untuk kasir
/// yang sedang antre. Cukup dibuktikan sekali per sesi aplikasi.
final Set<String> _statusUnsupported = {};
Future<NetworkPrintResult> printTicket({
required String host,
required List<int> data,
int port = 9100,
Duration connectTimeout = const Duration(seconds: 10),
}) async {
Socket? socket;
try {
socket = await Socket.connect(host, port, timeout: connectTimeout);
} catch (e) {
log('Tidak bisa membuka socket ke $host:$port', name: _logName, error: e);
return const NetworkPrintResult(
failure: NetworkPrintFailure.unreachable,
);
}
final reader = _SocketReader();
late StreamSubscription<List<int>> subscription;
try {
subscription = socket.listen(
reader.add,
onError: (Object e) => log('Socket error', name: _logName, error: e),
cancelOnError: false,
);
final before = await _readStatus(socket, reader, host);
if (before.answered) {
final blocking = before.blockingFailure;
if (blocking != null) {
log('Printer $host menolak: ${blocking.name}', name: _logName);
return NetworkPrintResult(failure: blocking, statusSupported: true);
}
}
socket.add(data);
await socket.flush();
// Kertas bisa habis di tengah struk. Cetakan yang terpotong sama
// tidak bergunanya dengan yang tidak keluar sama sekali, jadi kondisi
// ini dilaporkan sebagai gagal supaya kasir mencetak ulang.
final after = before.answered
? await _readStatus(socket, reader, host)
: const _PrinterStatus.unanswered();
if (after.answered) {
final blocking = after.blockingFailure;
if (blocking != null) {
log('Printer $host bermasalah setelah cetak: ${blocking.name}',
name: _logName);
return NetworkPrintResult(failure: blocking, statusSupported: true);
}
}
return NetworkPrintResult(
statusSupported: before.answered,
paperNearEnd: after.paperNearEnd || before.paperNearEnd,
);
} catch (e) {
log('Gagal mengirim data ke $host:$port', name: _logName, error: e);
return const NetworkPrintResult(
failure: NetworkPrintFailure.writeFailed,
statusSupported: false,
);
} finally {
await subscription.cancel().catchError((_) {});
try {
await socket.close();
} catch (_) {
// Printer kadang menutup duluan; tidak ada yang bisa diperbuat.
}
socket.destroy();
}
}
Future<_PrinterStatus> _readStatus(
Socket socket,
_SocketReader reader,
String host,
) async {
if (_statusUnsupported.contains(host)) {
return const _PrinterStatus.unanswered();
}
final printer = await _query(socket, reader, _statusPrinter);
// Printer yang tidak mendukung DLE EOT tidak menjawab sama sekali.
// Perlakukan sebagai "tidak diketahui", bukan sebagai error — kalau tidak,
// printer semacam itu jadi tidak bisa dipakai sama sekali.
if (printer == null) {
log(
'Printer $host tidak menjawab status; pemeriksaan kondisi kertas '
'dilewati untuk seterusnya',
name: _logName,
);
_statusUnsupported.add(host);
return const _PrinterStatus.unanswered();
}
final offline = await _query(socket, reader, _statusOffline);
final paper = await _query(socket, reader, _statusPaper);
return _PrinterStatus(
answered: true,
offline: printer & 0x08 != 0,
coverOpen: (offline ?? 0) & 0x04 != 0,
paperOut: ((offline ?? 0) & 0x20 != 0) || ((paper ?? 0) & 0x60 != 0),
paperNearEnd: (paper ?? 0) & 0x0C != 0,
);
}
/// Mengirim satu perintah status dan menunggu satu byte balasan.
/// `null` berarti printer tidak menjawab atau menjawab dengan format asing.
Future<int?> _query(
Socket socket,
_SocketReader reader,
List<int> command,
) async {
reader.clear();
socket.add(command);
await socket.flush();
final byte = await reader.readByte(_statusTimeout);
if (byte == null) return null;
// Balasan DLE EOT selalu punya pola tetap: bit0=0, bit1=1, bit4=1, bit7=0.
// Byte yang tidak cocok berarti bukan balasan status, jadi lebih aman
// dianggap tidak menjawab daripada salah tafsir.
if (byte & 0x93 != 0x12) {
log('Balasan status tidak dikenali: 0x${byte.toRadixString(16)}',
name: _logName);
return null;
}
return byte;
}
}
class _PrinterStatus {
final bool answered;
final bool offline;
final bool coverOpen;
final bool paperOut;
final bool paperNearEnd;
const _PrinterStatus({
required this.answered,
this.offline = false,
this.coverOpen = false,
this.paperOut = false,
this.paperNearEnd = false,
});
const _PrinterStatus.unanswered()
: answered = false,
offline = false,
coverOpen = false,
paperOut = false,
paperNearEnd = false;
/// Kondisi yang membuat struk tidak akan keluar. Diperiksa berurutan dari
/// yang paling spesifik supaya pesan ke kasir langsung menyebut tindakannya.
NetworkPrintFailure? get blockingFailure {
if (paperOut) return NetworkPrintFailure.paperOut;
if (coverOpen) return NetworkPrintFailure.coverOpen;
if (offline) return NetworkPrintFailure.offline;
return null;
}
}
/// Menampung byte yang datang dari socket dan membiarkannya dibaca satu per
/// satu dengan batas waktu. Socket adalah stream sekali-langganan, jadi
/// pembacaannya harus lewat satu subscription untuk seluruh sesi.
class _SocketReader {
final List<int> _buffer = [];
Completer<void>? _waiter;
void add(List<int> data) {
_buffer.addAll(data);
_waiter?.complete();
_waiter = null;
}
void clear() => _buffer.clear();
Future<int?> readByte(Duration timeout) async {
if (_buffer.isEmpty) {
final waiter = Completer<void>();
_waiter = waiter;
try {
await waiter.future.timeout(timeout);
} on TimeoutException {
_waiter = null;
return null;
}
}
if (_buffer.isEmpty) return null;
return _buffer.removeAt(0);
}
}