TiprePOS · Diagnóstico de impresión

Por qué la impresora se queda “fuera de servicio” y reimprime en bucle

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.

Stack: Flutter / Dart · ti_printer_plugin Transporte: USB (terminal cableado) Reporte técnico: 23 Jun 2026, 00:00 Modo: análisis (sin cambios aplicados)
P0 · causa directa del incidente P1 · confiabilidad P2 · higiene

01 El reporte del técnico → qué dice el código

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.”
Red / servicio de cobro caído · se arregla cambiando el cable
Capa física. Fuera del código de impresión, pero es el disparador de toda la cadena de fallas de la impresora.
“Impresora apagada y fuera de servicio” · 3 reinicios
Causa A. El puerto USB nunca se cierra → handle huérfano → la reconexión no puede reabrir el puerto → solo recupera reiniciando el proceso.
Bucle de reimpresión de “los 3 tickets” · para al cambiar la impresora
Causa B. “Bytes aceptados = impreso”: se acumulan copias en el buffer de la impresora y se vacían de golpe al recuperar. Cambiar el equipo limpió el buffer.

02 La falla de fondo (una sola)

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í.

  1. print → _onPrintReceiptEvent / _onStartPrintingVouchers
  2. gate previo → validateBeforeCriticalPoint()checkStatus()isReadyToPrint
  3. interpreter TM-T20IIIL ignora paperResponsehasPaper=true siempre
  4. envío → sendCommandToUsb() devuelve true al aceptar el buffer
  5. BLoC emite PrintStatus.success → UI: “Comprobante impreso” (sin papel / en buffer)

03 Causas raíz, priorizadas y confirmadas en código

P0 · A La impresora queda “fuera de servicio” y solo recupera reiniciando

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)

Fix: habilitar 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”.

P0 · B Bucle de reimpresión / tickets duplicados

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)

Fix: que “impreso” signifique confirmado (Causa D); no reenviar vouchers aceptados-pero-no-confirmados; no llenar el buffer del equipo cuando no está listo.

P0 · C El control de “listo” es ciego al papel

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)

Fix: que T20IIIL lea DLE EOT 4 (fin de rollo, bits 5–6); nunca caer en silencio a un interpreter ciego al papel; pasar siempre el modelo configurado al servicio.

P0 · D “Enviar” devuelve éxito al aceptar bytes, sin confirmar impresión

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

Fix: tras cada envío, releer DLE EOT y emitir success solo con online+papel+sin error confirmados; si no, error. Esto vuelve imposible confundir el duplicado de B con un éxito.

04 Secundario · confiabilidad e higiene

P1 Sin timeout en las lecturas de estado USB

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

Fix: envolver lecturas y envío en .timeout(); en timeout, tratar como no-listo/error.

P1 Retry no idempotente

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

P2 Trampas de diseño y código muerto

printer_status.dart:62–79 · printer_bloc.dart:1462/1773/1296–1310 · printer_status_handler.dart · build_vale_listener.dart:105–136

05 Orden de remediación recomendado

  1. Causa A — habilitar closeUsbPort(). Cambio mínimo; termina con el “reiniciar para recuperar”.
  2. Causa C — usar el modelo configurado + enseñar a T20IIIL a leer el papel. Restaura el control de papel.
  3. Causa D + timeouts — confirmación post-envío + .timeout(). “Éxito” pasa a significar impreso.
  4. Causa B — confirmación/idempotencia por voucher para que las reimpresiones no re-bufferizen duplicados.
  5. Limpieza de higiene (P2).

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).

06 Verificación (reproducir el incidente en hardware)

07 Afirmaciones revisadas y descartadas