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 _statusUnsupported = {}; Future printTicket({ required String host, required List 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> 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 _query( Socket socket, _SocketReader reader, List 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 _buffer = []; Completer? _waiter; void add(List data) { _buffer.addAll(data); _waiter?.complete(); _waiter = null; } void clear() => _buffer.clear(); Future readByte(Duration timeout) async { if (_buffer.isEmpty) { final waiter = Completer(); _waiter = waiter; try { await waiter.future.timeout(timeout); } on TimeoutException { _waiter = null; return null; } } if (_buffer.isEmpty) return null; return _buffer.removeAt(0); } }