fix: pulihkan koneksi Bluetooth dan deteksi kegagalan printer jaringan
Bluetooth: percobaan ulang connect sebelumnya tidak menutup socket lebih dulu, padahal 'read failed, socket might closed or timeout' justru berarti socket RFCOMM masih setengah terbuka — ketiga percobaan gagal identik dan retry-nya sia-sia. Tiap percobaan ulang kini didahului disconnect paksa. connectBluetooth mengembalikan sukses begitu ADA koneksi tanpa memeriksa apakah itu printer yang diminta, dan tidak pernah mencatat MAC yang aktif. Kini memakai jalur yang sama dengan mencetak. Izin runtime Bluetooth sebelumnya hanya diminta dari dialog pilih perangkat. Karena reinstall mencabut izin, kasir yang tidak pernah membuka dialog itu gagal cetak tanpa penjelasan. Status adapter, izin, dan pairing kini ikut di log, dan printer yang tidak ter-pairing digagalkan langsung dengan pesan yang menyebut tindakannya. Jaringan: printTicket hanya menaruh byte di socket lalu menutup koneksi, tidak pernah membaca balik, sehingga kertas habis atau cover terbuka terlihat sama dengan sukses — itu sebabnya kegagalan kitchen tidak pernah muncul di Crashlytics. NetworkPrinterClient baru menanyakan kondisi printer lewat DLE EOT sebelum dan sesudah mencetak, dengan degradasi anggun untuk printer yang tidak mendukungnya. Panggilan disconnect() ganda dihapus; printTicket sudah flush dan close sendiri, dan panggilan kedua tanpa await membuat error-nya tercatat sebagai crash fatal palsu. Ditambah jejak per station ke Crashlytics. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,6 @@ import 'dart:developer';
|
||||
|
||||
import 'package:dartz/dartz.dart' hide Order;
|
||||
import 'package:firebase_crashlytics/firebase_crashlytics.dart';
|
||||
import 'package:flutter_esc_pos_network/flutter_esc_pos_network.dart';
|
||||
import 'package:injectable/injectable.dart' hide Order;
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:print_bluetooth_thermal/print_bluetooth_thermal.dart';
|
||||
@@ -17,6 +16,7 @@ import '../../../presentation/components/print/print_ui.dart';
|
||||
import '../../auth/datasources/local_data_provider.dart';
|
||||
import '../../outlet/datasources/local_data_provider.dart';
|
||||
import '../datasource/local_data_provider.dart';
|
||||
import '../datasource/network_printer_client.dart';
|
||||
import '../printer_dtos.dart';
|
||||
|
||||
@LazySingleton(as: IPrinterRepository)
|
||||
@@ -40,6 +40,11 @@ class PrinterRepository implements IPrinterRepository {
|
||||
/// tiket dua order tidak saling menyelip.
|
||||
final Lock _jobLock = Lock();
|
||||
|
||||
/// Menggantikan `flutter_esc_pos_network` yang hanya mengirim byte lalu
|
||||
/// menutup koneksi. Klien ini menanyakan kondisi printer dan membaca
|
||||
/// jawabannya, sehingga kertas habis tidak lagi terlihat sebagai sukses.
|
||||
final NetworkPrinterClient _networkClient = NetworkPrinterClient();
|
||||
|
||||
String? _connectedMac;
|
||||
|
||||
@override
|
||||
@@ -484,31 +489,45 @@ class PrinterRepository implements IPrinterRepository {
|
||||
List<int> printData,
|
||||
) async {
|
||||
try {
|
||||
final printResult = await _printNetwork(printer.address, printData);
|
||||
final result = await _networkClient.printTicket(
|
||||
host: printer.address,
|
||||
data: printData,
|
||||
);
|
||||
|
||||
if (!printResult) {
|
||||
FirebaseCrashlytics.instance.recordError(
|
||||
'Failed to print to Network Printer',
|
||||
null,
|
||||
reason: 'Failed to print to Network Printer',
|
||||
information: [
|
||||
'function: _printNetwork(printer.address, printData)',
|
||||
'Printer: ${printer.name}',
|
||||
'ipAddress: ${printer.address}',
|
||||
'bytes: ${printData.length}',
|
||||
'in: $_logName',
|
||||
],
|
||||
);
|
||||
// Dulu ini `right(false)`, yang lolos sebagai sukses ke pemanggil —
|
||||
// kasir melihat "berhasil dicetak" padahal tidak ada kertas keluar.
|
||||
return left(
|
||||
PrinterFailure.dynamicErrorMessage(
|
||||
'Tidak bisa mencetak ke ${printer.name} (${printer.address})',
|
||||
),
|
||||
if (result.isSuccess) {
|
||||
if (result.paperNearEnd) {
|
||||
log('Kertas ${_printerLabel(printer)} menipis', name: _logName);
|
||||
}
|
||||
|
||||
log(
|
||||
'Cetak ${_printerLabel(printer)} selesai — kondisi printer '
|
||||
'${result.statusSupported ? "dikonfirmasi" : "tidak bisa ditanyakan"}',
|
||||
name: _logName,
|
||||
);
|
||||
|
||||
return right(true);
|
||||
}
|
||||
|
||||
return right(true);
|
||||
final failure = result.failure!;
|
||||
|
||||
FirebaseCrashlytics.instance.recordError(
|
||||
'Network print failed: ${failure.name}',
|
||||
null,
|
||||
reason: 'Network print failed: ${failure.name}',
|
||||
information: [
|
||||
'Printer: ${printer.name}',
|
||||
'ipAddress: ${printer.address}',
|
||||
'bytes: ${printData.length}',
|
||||
'statusSupported: ${result.statusSupported}',
|
||||
'in: $_logName',
|
||||
],
|
||||
);
|
||||
|
||||
return left(
|
||||
PrinterFailure.dynamicErrorMessage(
|
||||
_networkFailureMessage(printer, failure),
|
||||
),
|
||||
);
|
||||
} catch (e, stackTrace) {
|
||||
FirebaseCrashlytics.instance.recordError(
|
||||
e,
|
||||
@@ -523,45 +542,34 @@ class PrinterRepository implements IPrinterRepository {
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _printNetwork(String ipAddress, List<int> printData) async {
|
||||
try {
|
||||
final printer = PrinterNetworkManager(
|
||||
ipAddress,
|
||||
port: 9100,
|
||||
timeout: const Duration(seconds: 15),
|
||||
);
|
||||
/// Pesan yang menyebut tindakan yang harus dilakukan kasir, bukan istilah
|
||||
/// teknis — orang di depan mesin perlu tahu harus mengganti kertas atau
|
||||
/// menutup cover, bukan membaca kode error.
|
||||
String _networkFailureMessage(Printer printer, NetworkPrintFailure failure) {
|
||||
final label = _printerLabel(printer);
|
||||
|
||||
final PosPrintResult connect = await printer.connect();
|
||||
if (connect != PosPrintResult.success) {
|
||||
log('Failed to connect to Network printer: ${connect.msg}');
|
||||
return false;
|
||||
}
|
||||
|
||||
final PosPrintResult printing = await printer.printTicket(printData);
|
||||
printer.disconnect();
|
||||
|
||||
if (printing != PosPrintResult.success) {
|
||||
log('Failed to print via Network printer: ${printing.msg}');
|
||||
return false;
|
||||
}
|
||||
|
||||
log('Successfully printed via Network printer: $ipAddress');
|
||||
return true;
|
||||
} catch (e, stackTrace) {
|
||||
FirebaseCrashlytics.instance.recordError(
|
||||
e,
|
||||
stackTrace,
|
||||
reason: 'Error printing via Network',
|
||||
information: [
|
||||
'function: _printNetwork(String ipAddress, List<int> printData)',
|
||||
'ipAddress: $ipAddress',
|
||||
],
|
||||
);
|
||||
log('Error printing via Network: $e');
|
||||
return false;
|
||||
}
|
||||
return switch (failure) {
|
||||
NetworkPrintFailure.unreachable =>
|
||||
'Printer $label tidak terjangkau. Pastikan printer menyala dan berada di jaringan yang sama dengan tablet.',
|
||||
NetworkPrintFailure.paperOut =>
|
||||
'Kertas di printer $label habis. Ganti kertasnya, lalu cetak ulang.',
|
||||
NetworkPrintFailure.coverOpen =>
|
||||
'Cover printer $label terbuka. Tutup covernya, lalu cetak ulang.',
|
||||
NetworkPrintFailure.offline =>
|
||||
'Printer $label sedang offline atau dalam kondisi error. Periksa printernya, lalu cetak ulang.',
|
||||
NetworkPrintFailure.writeFailed =>
|
||||
'Pengiriman data ke printer $label terputus di tengah jalan. Cetak ulang untuk memastikan strukmya utuh.',
|
||||
};
|
||||
}
|
||||
|
||||
/// Nama printer di database sering kosong, jadi alamatnya selalu ikut
|
||||
/// disebut supaya pesan errornya tetap bisa ditindaklanjuti.
|
||||
String _printerLabel(Printer printer) {
|
||||
final name = printer.name.trim();
|
||||
return name.isEmpty ? printer.address : '$name (${printer.address})';
|
||||
}
|
||||
|
||||
|
||||
Future<bool> _writeBytesChunked(List<int> data) async {
|
||||
for (int i = 0; i < data.length; i += _kChunkSize) {
|
||||
final end = i + _kChunkSize > data.length ? data.length : i + _kChunkSize;
|
||||
@@ -683,6 +691,16 @@ class PrinterRepository implements IPrinterRepository {
|
||||
final result = await _printStation(plan, outlet, user.name);
|
||||
stopwatch.stop();
|
||||
|
||||
// Jejak per station ikut di setiap laporan Crashlytics berikutnya,
|
||||
// jadi saat ada keluhan "tiket tidak keluar" kita punya urutan
|
||||
// kejadiannya, bukan cuma satu error tanpa konteks.
|
||||
FirebaseCrashlytics.instance.log(
|
||||
'print ${plan.station.name} '
|
||||
'via ${plan.printer.type}(${plan.printer.address}) '
|
||||
'${result.isLeft() ? "GAGAL" : "terkirim"} '
|
||||
'dalam ${stopwatch.elapsedMilliseconds}ms',
|
||||
);
|
||||
|
||||
mark(
|
||||
plan.station,
|
||||
result.fold(
|
||||
|
||||
Reference in New Issue
Block a user