Análisis del subsistema de impresión enfocado en USB (síntoma de campo: “la app dice OK pero no sale papel”, la impresora queda apagada y se reimprimen tickets en bucle). Cada causa fue verificada leyendo el código fuente.
23 Jun 2026 · 00:00:54
“Lo reinicié como tres veces porque no me conectaba con el servicio de cobro… parecía desconectado de red. Cambié el cable y ahí no dio más problemas. Pero me seguía figurando la impresora apagada y fuera de servicio. Cuando restablecía la conexión, pasaba un cliente, imprimía su ticket, después empezaba a imprimir los otros tickets… los reimprimí y hacía un bucle de esos tres, de vuelta, de vuelta y de vuelta. Saqué la impresora, la cambié por otra y ya no volvió a pasar.”
El código trata “bytes aceptados por el endpoint USB” como “ticket impreso”. Nunca libera el handle del puerto, nunca confirma la impresión después de enviar, no tiene idempotencia por voucher, y la única validación de papel puede ser ciega al papel. Todos los síntomas de campo salen de ahí.
_onPrintReceiptEvent / _onStartPrintingVouchersvalidateBeforeCriticalPoint() → checkStatus() → isReadyToPrinthasPaper=true siempresendCommandToUsb() devuelve true al aceptar el bufferPrintStatus.success → UI: “Comprobante impreso” (sin papel / en buffer)connect() hace _safeCloseUsbPort() y luego openUsbPort(), pero el
cierre está comentado — aunque el mismo método del plugin sí se usa bien en testConnection.
Tras un corte de cable/USB/energía, el handle del puerto queda huérfano a nivel del SO; el
auto-reconnect llama openUsbPort sobre un puerto ocupado → ok == false →
reprograma → “disconnected” permanente. Solo matar el proceso libera el handle → reinicio.
lib/services/printer/usb_printer_service.dart:343–352 (cierre comentado) · :310 (mismo método sí funciona)
· :58 (isConnected sigue true) · lib/viewmodels/printer/printer_bloc.dart:1130–1212 (reconnect)
await _plugin.closeUsbPort() en _safeCloseUsbPort,
disconnect y dispose; garantizar cierre antes de cada reapertura en connect().
Es el cambio más chico y corta la falla del “reiniciar para recuperar”.Con la impresora offline/sin papel, la app sigue empujando bytes de los vouchers creyendo que imprimieron; el flujo del cliente + las reimpresiones manuales + los reenvíos del auto-reconnect acumulan copias en el buffer de recepción de la impresora, que se vacían de golpe al recuperar. No hay idempotencia por voucher ni registro de “cuál salió de verdad”: el loop reenvía todo el set. Un loop puramente de software habría seguido en la impresora nueva; como cambiar el equipo lo detuvo, eran trabajos en el buffer, amplificados por el reenvío del código.
lib/viewmodels/printer/printer_bloc.dart:1646–1730 (loop de vouchers, sin tracking) · lib/views/management_ticket/management_ticket_page.dart:692 (reimpresión del set)
El gate previo usa isReadyToPrint = isOnline && hasPaper && !isCoverOpen && !hasError.
El interpreter TM-T20IIIL — que además es el fallback silencioso cuando falla la detección de
modelo por USB — no lee paperResponse y devuelve withLimitedSensors(...), que
fuerza hasPaper:true. Una impresora sin papel pero “online” pasa el control. El RPT008 sí
lee el papel; cuál interpreter corre depende del modelo configurado. En cualquier caso el gate corre una
sola vez antes de enviar, así que quedarse sin papel a mitad del lote no se detecta.
lib/models/printer/tmt20iiil_status_interpreter.dart:38–132 (no usa paperResponse) ·
lib/models/printer/printer_status.dart:62–81 (hasPaper:true forzado) ·
lib/services/printer/usb_printer_service.dart:151–164 (fallback a T20IIIL) ·
lib/models/printer/rpt008_status_interpreter.dart:55–98 (sí lee papel)
sendCommandToUsb devuelve true cuando el endpoint acepta el buffer; el BLoC emite
success con eso. No se vuelve a leer el estado después de enviar — por eso un equipo sin papel o
en falla figura “impreso”.
lib/services/printer/usb_printer_service.dart:289 · lib/viewmodels/printer/printer_bloc.dart:1424–1433 · :1708–1720
success solo con online+papel+sin
error confirmados; si no, error. Esto vuelve imposible confundir el duplicado de B con un éxito.Una lectura colgada congela la impresión en printing; el listener de éxito/error de la pantalla de
resultado nunca dispara y el timer de redirección no arranca → el usuario queda en la pantalla.
usb_printer_service.dart:220, :242, :246 · result_payment_page.dart:277–291
.timeout(); en timeout, tratar como no-listo/error.Un solo intento de envío; un hipo transitorio de USB es pérdida silenciosa reportada como éxito. El retry debe ser consciente de idempotencia (Causa B/D) para no re-bufferizar duplicados.
printer_bloc.dart:1421 · :1708
withLimitedSensors forzando hasPaper:true — ningún modelo “limitado” puede reportar falta de papel.clearPrintMessage inconsistente (comentado en recibo único · activo en vouchers).emit es síncrono, el loop nunca corre).PrinterStatusHandler sin listenWhen — re-dispara en cada cambio de estado, pero está sin usar.build_vale_listener._listenForPrintCompletion abre un stream.listen que solo se cancela a los 10 s → apila suscripciones (solo navega, no reimprime).printer_status.dart:62–79 · printer_bloc.dart:1462/1773/1296–1310 · printer_status_handler.dart · build_vale_listener.dart:105–136
closeUsbPort(). Cambio mínimo; termina con el “reiniciar para recuperar”..timeout(). “Éxito” pasa a significar impreso.El proyecto está en Strict TDD — cada fix arranca con un test que falla. Hoy no hay tests de los
caminos de falla de impresión (test/viewmodels/printer/printer_bloc_test.dart cubre solo reconexión/MAC).
isReadyToPrint == false;
test de BLoC donde el envío se acepta pero el estado post-envío es no-listo → PrintStatus.error.
flutter test test/viewmodels/printer test/models/printer.error (printer_bloc.dart:1744–1757).emit es síncrono; es código muerto inofensivo.