272 lines
8.2 KiB
Dart
272 lines
8.2 KiB
Dart
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);
|
||
|
|
}
|
||
|
|
}
|