TiprePOS · Impresión USB · Plan de estabilización

Qué falta para que la impresión sea a prueba de campo

La versión nueva (TiprePOS20260702) corrige las 4 causas raíz del incidente de junio — verificado línea por línea. Este documento cubre lo que todavía puede fallar: 3 auditorías adversariales (BLoC nuevo, seam Dart↔nativo del plugin, cadena de provisioning VID/PID), con cada hallazgo verificado contra el código fuente antes de entrar acá.

Código: TiprePOS20260702 + printer_plugin (Linux) Transporte: USB · /dev/usb/lp* Estado: ✅ fases A–D implementadas (02 Jul 2026) — ver Anexo 06 TDD estricto: cada fix arranca con un test que falla
P0 · congela la app o regenera duplicados P1 · confiabilidad / operación P2 · higiene

01 Lo que la versión nueva YA arregló (verificado)

✔ CONFIRMADO Las 4 causas del incidente de junio están cerradas

⚠ El insight central de esta auditoría

La confirmación post-envío — el fix más importante — se apoya en una capa nativa que puede bloquear el thread principal de la UI y devolver bytes de estado contaminados. Peor: la confirmación hace 3 escrituras de estado justo después de llenar el buffer de la impresora con un ticket completo — el momento exacto de máxima exposición al bloqueo. La feature nueva amplifica la debilidad vieja. Por eso el plan endurece el plugin PRIMERO.

02 Puntos débiles verificados

P0 · W1 I/O USB bloqueante en el thread principal de GTK → la app entera se congela

open() con O_NONBLOCK comentado; send_command_to_usb es un loop de write() bloqueante; los method calls se atienden directo en el main loop (sin g_task ni thread). Si la impresora asserta BUSY (buffer lleno, tapa abierta, offline), write() bloquea → UI congelada. El .timeout() de Dart NO cancela el código nativo: tras el timeout, Dart sigue pero el thread nativo queda trabado y toda llamada posterior al plugin se encola detrás. Congelamiento total hasta que la impresora se recupere.

printer_plugin/linux/ti_printer_plugin.cc:189 (O_NONBLOCK comentado) · :220–261 (write loop) · :524–529 (main loop, sin offload)

Fix (Fase A): thread de I/O dedicado + respuestas async; O_NONBLOCK + poll(POLLOUT) con deadline (~5s) → false en vez de bloquear para siempre.

P0 · W2 Bytes de estado "viejos" contaminan la siguiente lectura

read_status_usb: envía DLE EOT → select() 500ms → UN read(). Si la respuesta llega tarde (>500ms), queda en el buffer del kernel y se devuelve como respuesta del siguiente comando (ej: la respuesta de papel EOT 4 leída como causa-offline EOT 2). No hay drain/flush del input antes de enviar. Esto corrompe exactamente las lecturas de las que dependen validateBeforeCriticalPoint y la confirmación post-envío.

printer_plugin/linux/ti_printer_plugin.cc:270–316

Fix (Fase A): drenar el input (reads no-bloqueantes hasta EAGAIN) antes de cada comando de estado y después de un timeout de escritura.

P0 · W3 Confirmación de un solo intento vs impresora ocupada + debounce del T20IIIL → falso "no se confirmó" → el operador reimprime → duplicados

_confirmPrintedAfterSend() hace UNA lectura de estado, sin ventana de gracia. Dos modos de falla verificados:

Resultado: el operador ve error, reimprime → ticket duplicado. Es el modo de falla del incidente de junio, ahora vía el loop humano.

printer_bloc.dart:2042–2051 (single-shot) · :1503–1527 (recibo) · ~:1739 (voucher) · tmt20iiil_status_interpreter.dart:31–32,122–123 (debounce ×6)

Fix (Fase B): ventana de gracia (reintentar estado ~3 veces / 2–3s); modo "lectura fresca" en el interpreter para la confirmación (que no herede la ventana ciega de 6 lecturas); mensaje "Verifique si salió el ticket antes de reimprimir".

P0 · W4 La confirmación puede marcar "conexión perdida" en medio de la impresión

_confirmPrintedAfterSend y validateBeforeCriticalPoint hacen add(StatusUpdatedEvent(status)). Un estado transitorio con error rutea a _markConnectionLost → disconnected + reconexión, con una impresión en vuelo. En el loop de vouchers eso aborta el lote sin registro de cuáles salieron de verdad.

printer_bloc.dart:2045 · :2060 · handler _onStatusUpdated

Fix (Fase B): mientras printStatus == printing, no marcar conexión perdida por errores transitorios (diferir o exigir N consecutivos), o no emitir StatusUpdatedEvent desde el path de confirmación.

P1 · W5 Una falla de provisioning se lee como falla de hardware

El connect nuevo exige VID/PID válido (correcto), pero: _parseHexId('UNKNOWN') devuelve null sin loguear; el mensaje al operador es "Impresora fuera de servicio…" — suena a impresora rota, no a config; y la pantalla de settings muestra idVendor/idProducto crudos sin indicador de validez. Un técnico va a cambiar la impresora en vez de corregir la config. Gate de rollout: si la flota no está provisionada con VID/PID hex reales, esta versión pasa de "imprime (a veces mal)" a "no conecta".

apply_terminal_configuration.dart:843–848 (_parseHexId silencioso) · :694–708 (aborta y reporta, mensaje genérico) · tab_printer.dart:71–78 (sin badge)

Fix (Fase C): log warn en parse inválido; mensaje accionable ("Corrija idVendor/idProducto en la configuración del terminal"); badge de validez en settings; auditoría de la config de la flota ANTES del deploy (tabla VID/PID de referencia en status_interpreter_factory.dart:22–36).

P1 · W6 Parsing no defensivo en el canal Dart↔nativo

PrinterDeviceInfo.fromMap usa casts as int/as String: un tipo inesperado del lado nativo crashea fuera del try-catch de getUsbPrinters (que solo atrapa PlatformException).

printer_plugin/lib/printer_device_info.dart:27–34

Fix (Fase A): casts tolerantes con defaults o descartar la entrada malformada.

P1 · W7 Cero tests para TODO el comportamiento nuevo

Sin tests: _parseHexId, rechazo de connect sin VID/PID, lógica de confirmación, byte de papel del T20IIIL, contaminación/timeout de estado, mapeo de errores del canal. El comportamiento que arregló el incidente no tiene red de regresión.

Fix (Fase D): suite completa — es la red que impide que el incidente vuelva en un refactor futuro.

P2 Higiene (W8–W11)

03 Claims de la auditoría que verificamos y RECHAZAMOS

04 Plan de estabilización — 4 fases

A

Endurecer el plugin nativo

✔ IMPLEMENTADAprinter_plugin/ · resuelve W1, W2, W6
  1. Thread de I/O dedicado: mover open/write/read/close fuera del main loop (GTask o un único thread de I/O + respuestas async). Un solo thread preserva la serialización que asume _portLockTail en Dart.
  2. Escrituras acotadas: abrir con O_NONBLOCK; loop de escritura con poll(POLLOUT) + deadline (~5s) → false en timeout. Manejar EAGAIN.
  3. Drain de input: antes de cada comando de estado (y tras un timeout de escritura), reads no-bloqueantes hasta EAGAIN para descartar bytes viejos.
  4. fromMap defensivo en printer_device_info.dart.
B

Robustecer la confirmación post-envío

✔ IMPLEMENTADAprinter_bloc.dart + interpreter · resuelve W3, W4
  1. Ventana de gracia: _confirmPrintedAfterSend reintenta el estado hasta ~3 veces / 2–3s; éxito apenas isReadyToPrint; error solo al agotar la ventana.
  2. No matar la conexión en medio de un print: con printStatus == printing, no ejecutar _markConnectionLost por errores transitorios (o no emitir StatusUpdatedEvent desde la confirmación).
  3. Lectura fresca vs debounce: el interpreter T20IIIL debe exponer un modo de confirmación que no herede la ventana ciega de 6 lecturas.
  4. UX de "enviado, sin confirmar": el mensaje debe decir que el ticket PUDO haber salido — "Verifique si salió el ticket antes de reimprimir" — para cortar el loop humano de duplicados.
C

Provisioning legible para operación

✔ IMPLEMENTADAconfig + settings · resuelve W5
  1. _parseHexId: warn-log cuando un valor no vacío no parsea ('UNKNOWN' incluido).
  2. Mensaje accionable en el abort: "Configuración inválida: idVendor/idProducto. Corrija la configuración del terminal en el backend" + badge de validez junto a Vendor/Product id en el tab de settings.
  3. Gate de rollout (ops): auditar la config backend de TODA la flota — idVendor/idProducto hex válidos y coincidentes con la impresora física de cada terminal.
D

Tests + higiene

✔ IMPLEMENTADAapp + plugin · resuelve W7–W11
  1. TDD estricto por cada fix + unit tests: edges de _parseHexId; rechazo de connect; gracia de confirmación (servicio fake busy→ready); byte de papel + debounce del T20IIIL; fromMap malformado; mapeo de errores del canal.
  2. Separar keys info/warn de log-once; guards isClosed; guard de dispatch por estado del bloc en management_ticket; serial marcado "no soportado en Linux".

05 Verificación — matriz de banco (replay del incidente)

#EscenarioResultado esperado
1Ticket largo (QR pesado)Confirmación exitosa sin falso "no se confirmó"; la UI nunca se congela.
2Sin papel a mitad de un lote de 3 vouchers → reponer papelVoucher fallido reportado con "verifique antes de reimprimir"; sin ráfaga de duplicados bufferizados al recuperar.
3Abrir/cerrar tapa (oscilación) e imprimir inmediatamenteSin falso error por la ventana ciega del debounce ×6.
4Desenchufar USB en medio de un print → re-enchufarApp responsiva (sin freeze del main thread); reconexión por VID/PID sin reiniciar la app.
5Terminal con idVendor='UNKNOWN'Error de configuración accionable y visible en UI/settings — no un genérico "fuera de servicio".
6Soak 1h: monitoreo continuo + impresiones repetidasSin starvation del port-lock; sin lecturas de estado contaminadas en los logs.

Unit: flutter test test/viewmodels/printer test/models/printer (app) · flutter test en printer_plugin/ (canal + parsing).

06 Anexo — Implementación aplicada (02 Jul 2026)

Las cuatro fases del plan se implementaron con TDD estricto (test rojo primero, después el fix). Evidencia: 290/290 tests de la app + 17/17 tests del plugin en verde, flutter analyze limpio en todos los archivos tocados.

FaseQué se arreglóArchivos claveTests nuevos
A
plugin
1.0.16→1.0.17
W1: todo el I/O USB corre en un thread dedicado (pool FIFO de 1 + respuesta async vía g_idle_add) — una impresora en BUSY ya no congela la UI.
W1: open() con O_NONBLOCK | O_NOCTTY (antes comentado); escrituras acotadas con poll(POLLOUT) + deadline (5s datos / 1s estado) → error en vez de colgar.
W2: drain de input antes de cada comando DLE EOT — la causa raíz que 1.0.15 parchaba en Dart con status.last y gaps de 80ms.
W6: fromMap defensivo + filtrado de entradas malformadas en getUsbPrinters.
W11: fsync() sobre char device eliminado.
linux/ti_printer_plugin.cc
lib/printer_device_info.dart
lib/ti_printer_plugin_method_channel.dart
CHANGELOG + pubspec
6 fromMap
+1 canal
(17/17 total)
B
app
W3: ventana de gracia en _confirmPrintedAfterSend (3 intentos × 900ms, inyectable por constructor); nuevo checkStatusForConfirmation() en PrinterService y los 4 servicios; interpretConfirmation() en el T20IIIL = lectura fresca sin la ventana ciega del debounce ×6 (sigue alimentando el contador del monitoreo).
W4: la confirmación ya no emite StatusUpdatedEvent; con una impresión en vuelo, _onStatusUpdated difiere los errores transitorios (offline/comunicación) — deviceNotFound sigue cortando.
UX anti-duplicado: "No se confirmó la impresión (…). Verifique si el ticket salió impreso antes de reimprimir" en recibo único y vouchers.
printer_bloc.dart
status_interpreter.dart
tmt20iiil_status_interpreter.dart
services/printer/*.dart
4 bloc-confirm
6 interpreter
C
config
W5: HexIdParser público y testeable — un valor no parseable ('UNKNOWN') ahora loguea warning en vez de fallar en silencio; validación pre-vuelo (PASO 2.5) en apply_terminal_configuration con mensaje accionable: «Configuración de impresora inválida: idVendor="UNKNOWN"… Corrija la configuración del terminal en el backend (ej: 0x04B8)» — ya no el genérico "fuera de servicio" que hacía cambiar impresoras sanas; badge "⚠ INVÁLIDO" junto a Vendor/Product id en settings. core/helpers/hex_id_parser.dart (nuevo)
routing/apply_terminal_configuration.dart
views/settings/widgets/tab_printer.dart
10 parser
D
higiene
W8: keys de log-once separadas por nivel (info/warn) en bloc y servicio USB — ya no se suprimen logs entre sí.
W9: 7 guards isClosed tras awaits largos en los handlers de impresión + guard en _scheduleClearPrintChip; los guards anti doble-dispatch de management_ticket ahora consultan también printerBloc.state.printStatus (no solo el flag del widget).
W10: serial marcado "⚠ NO SOPORTADO EN ESTE EQUIPO" en settings.
W7: resuelto por la suite completa de esta implementación.
printer_bloc.dart
usb_printer_service.dart
management_ticket_page.dart
tab_printer.dart
cubierto por
suites A–C

PENDIENTE — EQUIPO Lo que falta antes del rollout (no se puede hacer desde la máquina de desarrollo)

  1. Compilar el plugin en Linux. El .cc nuevo NO se compiló acá (máquina Windows, target Linux POS) — el build de ustedes es el gate de compilación. Después: publicar ti_printer_plugin 1.0.17 y subir el pubspec de la app (hoy consume ^1.0.14 de pub.dev).
  2. Correr la matriz de banco (sección 05) con impresora física — en particular escenarios 1 (ticket largo sin falso "no se confirmó"), 3 (oscilación de tapa + print inmediato) y 4 (desenchufe en vuelo sin freeze).
  3. Auditar el provisioning de la flota: idVendor/idProducto hex válidos y coincidentes con la impresora física de cada terminal. Gate duro: con esta versión una config inválida bloquea la conexión con error accionable — mejor que imprimir al vacío, pero hay que corregir la config antes del deploy.