TiprePOS · Impresión USB · Revisión del drop 20260716

Los fixes están todos — y ASB es un gran paso. Queda UN hueco por cerrar.

Revisión de TiprePOS20260716 + ti_printer_plugin-main (v1.1.4) contra el fix de estabilización W1–W11 del 02 Jul. Cada afirmación de este doc está verificada leyendo el código y corriendo la suite: 155/155 tests en verde en el árbol nuevo.

Base comparada: fix 20260702 + plugin 1.0.17 Drop nuevo: 20260716 + plugin 1.1.4 Veredicto: ✅ W1–W11 incorporados · ⚠ 1 hallazgo nuevo (N1) Incluye: 🔧 fix propuesto listo para aplicar — §06 (NO aplicado)

01 Qué se verificó — el fix anterior sobrevivió completo

✔ CONFIRMADO Los 11 puntos débiles (W1–W11) siguen cerrados en el drop nuevo

02 Lo nuevo del equipo: ASB (Auto Status Back) — bien ejecutado

✔ MEJORA Estado en tiempo real empujado por la impresora (GS a)

Para TM-T88V y TM-T20IIIL, la impresora ahora empuja frames de estado de 4 bytes ante cada cambio (papel, tapa, error) — un loop de lectura cada 60ms los captura. RPT008/006B quedan en DLE EOT con razón documentada (los adaptadores USB-serial no generan tráfico ASB). Detalles que muestran criterio:

ti_printer_plugin-main/linux/ti_printer_plugin.cc (+265 sobre 1.0.17) · lib/models/printer/asb_status.dart · usb_printer_service.dart:664–762 (loop + pausa) · status_interpreter_factory.dart:33 (supportsAsb)

03 ⚠ EL PROBLEMA — N1: confirmación desde cache (Causa D, reintroducida suave)

⚠ Por qué importa

El fix de la Causa D estableció: "éxito" = releer la impresora DESPUÉS de enviar. Con ASB activo, la confirmación ahora devuelve un snapshot cacheado — que puede ser de antes del envío. En el escenario exacto del incidente de junio (ticket largo + fin de rollo a mitad de la alimentación), el sistema puede volver a decir "impreso correctamente" sin que salga papel. Es el peor modo de falla para un POS de autoservicio: el cliente se va sin su comprobante y nadie se entera.

N1 · ALTO La confirmación post-envío no lee la impresora si hay cache ASB

_readAndInterpretStatus(confirmation: true) devuelve el cache ASB sin consultar el dispositivo: primero el "fresco" (≤12s) y, si no hay, el último conocido sin límite de edad — el log lo dice literal: "Confirmacion usando estado ASB fresco, sin consultar DLE". El fallback a lectura DLE real existe (:474) pero solo corre cuando no hay cache.

  1. t=0.0s  → sendRawData(ticket) — pausa el loop ASB, escribe, reanuda. Bytes aceptados ✓
  2. t=0.5s  → la impresora alimenta el ticket… se acaba el papel a mitad de la impresión
  3. t=0.5s  → la impresora emite el frame ASB "sin papel" → queda en el buffer del kernel, el loop aún no lo leyó
  4. t=0.1s* → _confirmPrintedAfterSend() intento 1 → cache ASB con timestamp PRE-envío ("con papel") → isReadyToPrint == true
  5. → la ventana de gracia hace short-circuit en el primer "listo"PrintStatus.success → UI: "Comprobante impreso"
  6. t=0.7s  → el loop lee el frame "sin papel" → el monitoreo lo muestra… tarde: el éxito ya se reportó

* la confirmación corre inmediatamente después de que el send retorna — antes de que el loop (60ms + cola del port-lock) alcance a leer el frame pendiente.
usb_printer_service.dart:416–432 (cache primero, "sin consultar DLE") · :424–432 (acepta "último conocido" sin límite de edad) · :474 (fallback DLE solo si NO hay cache) · no existe tracking de _lastSendCompletedAt (verificado por grep).

Fix sugerido (chico y quirúrgico): registrar _lastSendCompletedAt al terminar cada sendRawBytes, y en el path de confirmación aceptar el cache ASB solo si su timestamp es posterior al último envío; si no, caer a la lectura DLE que ya existe. Alternativa equivalente: para confirmation: true invertir el orden — DLE primero, cache como fallback (el clasificador nativo ya maneja la colisión DLE/ASB). El monitoreo puede seguir usando el cache tal como está. Test de regresión: fake service con cache "listo" pre-envío + frame "sin papel" post-envío → la confirmación debe reportar error, no éxito.

04 Observaciones menores (no bloquean)

N2 · MEDIO El "último conocido" sin límite de edad es más laxo que la ventana de 12s

Dentro del mismo path de N1: si el cache "fresco" (≤12s) expiró, la confirmación acepta _getLatestAsbStatus() — un estado sin cota de antigüedad (podría ser de minutos atrás si el loop se detuvo). Cualquier solución de N1 debería eliminar también este segundo escalón para el path de confirmación.

usb_printer_service.dart:424–432

N3 · BAJO Ventana de warm-up ASB tras conectar

Entre conectar y el primer frame ASB, checkStatus devuelve "Esperando estado de la impresora" (hasError: true) → una impresión disparada en esa ventana falla el pre-vuelo con "Impresora no lista". En la práctica es corta (GS a transmite el estado actual al habilitarse) y el watchdog de 15s la acota — pero conviene observarlo en banco: si aparece "Impresora no lista" justo después de reconectar, es esto.

usb_printer_service.dart:434–461

✔ REVISADO SIN HALLAZGO Cosas que chequeé y están bien

05 Recomendación

#AcciónCostoRiesgo si no se hace
1Cerrar N1/N2: confirmación solo acepta cache ASB post-envío (o DLE primero en confirmación) + test de regresión — código propuesto completo en §06~1 archivo, <30 líneas + test"Impreso correctamente" falso con fin de rollo a mitad de ticket — el modo de falla del incidente de junio, reaparecido
2Banco: escenario específico — ticket largo + quitar papel DURANTE la alimentación, con TM-T20IIIL/T88V (modelos ASB)1 sesión de bancoN1 no se detecta con los escenarios de la matriz anterior (que validaban el path DLE)
3Banco: observar warm-up post-reconexión (N3)incluido en #2"Impresora no lista" esporádico tras reconectar, sin explicación

06 🔧 Fix propuesto — código completo (NO aplicado)

Este código NO fue aplicado a ningún árbol. Está diseñado contra el fuente real de TiprePOS20260716/lib/services/printer/usb_printer_service.dart (los bloques "antes" son copia literal). Aplicar con TDD: primero el test del Cambio 6 (debe fallar en rojo), después los cambios 1–5 (verde).

Idea del fix en una línea: un estado ASB solo puede confirmar una impresión si fue recibido después de que el envío terminó. Se registra el instante del envío y la confirmación exige cache post-envío; si no hay, cae a la lectura DLE real que ya existe. El monitoreo no cambia.

CAMBIO 1 · Campo nuevo — junto a los campos ASB (~línea 51)
  PrinterStatus? _lastAsbStatus;
  DateTime? _lastAsbStatusAt;
  // FIX N1: instante en que terminó el último envío de datos. Un estado
  // ASB solo puede CONFIRMAR una impresión si es posterior a este momento.
  DateTime? _lastSendCompletedAt;
CAMBIO 2 · sendRawBytes — sellar el instante del envío (~línea 575)
      final success = await _withPausedAsbReads(() {
        return _pluginCall(
          () => _plugin.sendCommandToUsb(payload),
          timeout: _sendTimeout,
        );
      });

      if (success) {
        // FIX N1: a partir de acá, solo un frame ASB más nuevo que este
        // instante puede dar fe del estado POST-impresión.
        _lastSendCompletedAt = DateTime.now();
      }

      if (success && _payloadResetsPrinter(payload)) {
        await Future.delayed(_postInitAsbDelay);
        await _attemptEnableAsbIfSupported();
      }

      return success;
CAMBIO 3 · Helper nuevo — debajo de _getFreshAsbStatus() (~línea 115)
  /// Cache ASB válido para CONFIRMAR una impresión (Causa D): además de
  /// fresco, su timestamp debe ser POSTERIOR al último envío completado.
  /// Un snapshot pre-envío diría "con papel" aunque el rollo se haya
  /// agotado a mitad del ticket.
  PrinterStatus? _getPostSendAsbStatus() {
    final status = _getFreshAsbStatus();
    final at = _lastAsbStatusAt;
    if (status == null || at == null) return null;

    final sentAt = _lastSendCompletedAt;
    if (sentAt != null && !at.isAfter(sentAt)) return null;

    return status;
  }
CAMBIO 4 · _readAndInterpretStatus — el bloque de cache (líneas 416–432)
// ── ANTES (el problema N1/N2) ──────────────────────────────────────────
      final freshAsbStatus = _getFreshAsbStatus();
      if (freshAsbStatus != null) {
        if (confirmation) {
          _log('Confirmacion usando estado ASB fresco, sin consultar DLE');
        }
        return freshAsbStatus;
      }

      final lastAsbStatus = _getLatestAsbStatus();
      if (lastAsbStatus != null) {
        if (confirmation) {
          _log('Confirmacion usando ultimo estado ASB conocido, sin consultar DLE');
        } else {
          _log('Monitoreo ASB activo: sin novedades en la impresora');
        }
        return lastAsbStatus;
      }

// ── DESPUÉS ────────────────────────────────────────────────────────────
      if (confirmation) {
        // FIX N1/N2: para confirmar una impresión solo sirve un estado ASB
        // recibido DESPUÉS del envío. Sin cache post-envío se sigue de
        // largo a la lectura DLE real de más abajo (el clasificador nativo
        // ya descarta frames ASB que colisionen con la respuesta DLE).
        final postSendStatus = _getPostSendAsbStatus();
        if (postSendStatus != null) {
          _log('Confirmacion usando estado ASB posterior al envio');
          return postSendStatus;
        }
      } else {
        final freshAsbStatus = _getFreshAsbStatus();
        if (freshAsbStatus != null) {
          return freshAsbStatus;
        }

        final lastAsbStatus = _getLatestAsbStatus();
        if (lastAsbStatus != null) {
          _log('Monitoreo ASB activo: sin novedades en la impresora');
          return lastAsbStatus;
        }
      }
CAMBIO 5 · Los DOS fallbacks ASB — DLE sin respuesta (~línea 474) y catch (~línea 527)
// El mismo cambio en ambos sitios:
        final fallbackAsbStatus = confirmation
            ? _getLatestAsbStatus()          // ← sin límite de edad (N2)
            : _getFreshAsbStatus();
        final fallbackAsbStatus = confirmation
            ? _getPostSendAsbStatus()        // ← solo post-envío (N1/N2)
            : _getFreshAsbStatus();
CAMBIO 6 · Test de regresión propuesto — test/services/printer/usb_printer_service_confirm_test.dart (archivo NUEVO)
// Esqueleto orientativo: la escena exacta de N1. El servicio acepta un
// TiPrinterPlugin inyectado (constructor `plugin:`), así que se fakea el
// canal completo sin tocar nativo. Verificar los bytes ASB contra
// AsbStatus.tryParse antes de dar por bueno el fake.
import 'dart:typed_data';
import 'package:auto_compra/services/printer/usb_printer_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:ti_printer_plugin/ti_printer_plugin.dart';

/// Frame ASB "con papel"  : b1=0x10 (patrón estándar), b3 sin bits de papel.
final asbPaperOk  = Uint8List.fromList([0x10, 0x00, 0x00, 0x00]);
/// Frame ASB "sin papel"  : b3 & 0x0C == 0x0C (fin de rollo).
final asbPaperOut = Uint8List.fromList([0x10, 0x00, 0x0C, 0x00]);

class FakeAsbPlugin implements TiPrinterPlugin {
  bool sent = false;

  @override
  Future<bool> sendCommandToUsb(Uint8List command) async {
    sent = true;                       // el papel "se acaba" durante el envío
    return true;                       // bytes aceptados igual (Causa D)
  }

  @override
  Future<Uint8List> readFromUsb() async {
    // Antes del envío el loop ASB cachea "con papel"; después del envío
    // la impresora emite "sin papel".
    return sent ? asbPaperOut : asbPaperOk;
  }

  // openUsbPort/enableAsb => true · getUsbPrinters => device con el
  // VID/PID configurado · readStatusUsbDetailed => DLE "sin papel"
  // (0x72) cuando sent==true · resto: stubs.
  // ...
}

void main() {
  test('confirmación NO acepta cache ASB pre-envío (regresión N1)', () async {
    final plugin = FakeAsbPlugin();
    final service = UsbPrinterService(
      vendorId: 0x04B8, productId: 0x0E28, plugin: plugin,
    );

    await service.connect(/* config USB del fake */);
    await Future<void>.delayed(const Duration(milliseconds: 200));
    // precondición: el loop ASB ya cacheó "con papel" (pre-envío)

    final ok = await service.sendRawData(Uint8List.fromList([0x1B, 0x40]));
    expect(ok, isTrue); // bytes aceptados — eso NUNCA fue "impreso"

    final confirm = await service.checkStatusForConfirmation();

    // SIN el fix: devuelve el cache pre-envío ("con papel") => ready =>
    // falso "impreso correctamente". CON el fix: exige cache post-envío
    // o lee DLE real => reporta el sin-papel.
    expect(confirm.isReadyToPrint, isFalse,
        reason: 'la confirmación debe reflejar el estado POST-envío');
  });
}

✔ POR QUÉ ES SEGURO

Checklist de aplicación (TDD)
1. Crear el test del Cambio 6 → flutter test → debe FALLAR (rojo) reproduciendo N1.
2. Aplicar cambios 1–5 en usb_printer_service.dart → el test pasa (verde).
3. Regresión completa: flutter test test/models/printer test/viewmodels/printer test/core/helpers test/services — hoy 155/155, deben seguir verdes.
4. Banco: ticket largo + quitar papel DURANTE la alimentación en TM-T20IIIL/T88V → debe reportar error con "Verifique si el ticket salió impreso", nunca "Comprobante impreso".