// Package escpos emula el comportamiento de una impresora térmica ESC/POS: // interpreta el flujo de bytes, lo dibuja en un papel virtual y responde a // los comandos de estado igual que lo haría una impresora real. package escpos import ( "fmt" "image" "io" "strings" "sync" "time" ) // Profile describe el modelo de impresora emulado. type Profile struct { Key string Name string PaperDots int // ancho físico del papel en puntos (8 puntos/mm) WidthDots int // ancho imprimible en puntos HasCutter bool // sin cuchilla, GS V se ignora y el papel se arranca a mano CutterOffset int // distancia en puntos entre el cabezal y la cuchilla NativeQR bool // soporta GS ( k para QR ExtendedGSI bool // responde a GS I 65..69 (fabricante, modelo, serie...) ModelID byte TypeID byte VersionID byte Maker string Model string Firmware string Serial string USBVendorID uint16 USBProductID uint16 } var profiles = []Profile{ { Key: "80mm", Name: "Genérica 80 mm (estilo Epson TM)", PaperDots: 640, WidthDots: 576, HasCutter: true, CutterOffset: 96, NativeQR: true, ExtendedGSI: true, ModelID: 0x20, TypeID: 0x02, VersionID: 0x10, Maker: "EMULADOR", Model: "ESCPOS-EMU-80", Firmware: "1.00 ESC/POS", Serial: "EMU80-000001", USBVendorID: 0x1209, USBProductID: 0x0001, }, { Key: "58mm", Name: "Genérica 58 mm (clon barato, sin cuchilla)", PaperDots: 464, WidthDots: 384, HasCutter: false, CutterOffset: 64, NativeQR: false, ExtendedGSI: false, ModelID: 0x01, TypeID: 0x00, VersionID: 0x01, Maker: "EMULADOR", Model: "ESCPOS-EMU-58", Firmware: "0.9", Serial: "EMU58-000001", USBVendorID: 0x1209, USBProductID: 0x0002, }, } // Profiles devuelve los perfiles disponibles. func Profiles() []Profile { return append([]Profile(nil), profiles...) } // FindProfile busca un perfil por su clave ("80mm", "58mm"). func FindProfile(key string) (Profile, bool) { for _, p := range profiles { if p.Key == key { return p, true } } return Profile{}, false } // Status son las condiciones físicas que se simulan desde la interfaz. type Status struct { CoverOpen bool PaperEnd bool PaperNearEnd bool CutterError bool Offline bool // botón "offline" manual (p.ej. mantenimiento) DrawerOpen bool // sensor del cajón (pin 3 del conector) } // Blocked indica si la impresora ha dejado de imprimir. Los datos que // lleguen se quedan en el buffer hasta que se resuelva la condición. func (s Status) Blocked() bool { return s.CoverOpen || s.PaperEnd || s.CutterError || s.Offline } func (s Status) describe() string { var parts []string if s.CoverOpen { parts = append(parts, "tapa abierta") } if s.PaperEnd { parts = append(parts, "sin papel") } else if s.PaperNearEnd { parts = append(parts, "papel casi agotado") } if s.CutterError { parts = append(parts, "error de cuchilla") } if s.Offline { parts = append(parts, "offline") } if s.DrawerOpen { parts = append(parts, "cajón abierto") } if len(parts) == 0 { return "normal" } return strings.Join(parts, ", ") } // Ticket es un trozo de papel ya cortado. Es inmutable una vez creado. type Ticket struct { Seq int Time time.Time Cut string // "total", "parcial" o "arrancado" Image *image.Gray } // LogKind clasifica las entradas del registro. type LogKind int const ( LogCommand LogKind = iota LogText LogResponse LogEvent LogWarning ) // LogEntry es una línea del registro: los bytes recibidos y su significado. type LogEntry struct { Seq int Time time.Time Source string Kind LogKind Hex string Text string } // Session representa un canal de entrada (una conexión TCP, el USB...). // Cada sesión tiene su propio buffer de recepción; el estado de impresión // (negrita, alineación...) es global a la impresora, como en una real. type Session struct { p *Printer name string reply func([]byte) pending []byte outbox []byte closed bool } // Printer es la impresora emulada. Todos sus métodos son seguros para uso // concurrente. type Printer struct { mu sync.Mutex profile Profile status Status st mode line lineBuf paper *paper tickets []*Ticket ticketSeq int newTix []*Ticket sessions []*Session logs []LogEntry logSeq int capture []byte asbMask byte asbTo *Session fonts [2]*glyphFont curImg *image.Gray curImgVer int onChange func() onTicket func(*Ticket) logOut io.Writer } const ( maxLogEntries = 3000 maxTickets = 200 maxCapture = 32 << 20 maxPaperDots = 40000 // unos 5 m de papel sin cortar ) // NewPrinter crea una impresora con el perfil indicado. func NewPrinter(profile Profile) (*Printer, error) { fa, fb, err := loadFonts() if err != nil { return nil, err } p := &Printer{profile: profile, fonts: [2]*glyphFont{fa, fb}} p.st = defaultMode(profile.WidthDots) p.paper = newPaper(profile.PaperDots) p.curImgVer = -1 p.event("Impresora encendida: %s (%d puntos imprimibles)", profile.Name, profile.WidthDots) return p, nil } // SetOnChange registra una función que se llama (fuera del cerrojo) cada // vez que cambia algo visible. func (p *Printer) SetOnChange(fn func()) { p.mu.Lock() p.onChange = fn p.mu.Unlock() } // SetOnTicket registra una función que recibe cada ticket cortado. func (p *Printer) SetOnTicket(fn func(*Ticket)) { p.mu.Lock() p.onTicket = fn p.mu.Unlock() } // SetLogOutput vuelca además el registro en w (útil en modo sin GUI). func (p *Printer) SetLogOutput(w io.Writer) { p.mu.Lock() p.logOut = w p.mu.Unlock() } // Profile devuelve el perfil activo. func (p *Printer) Profile() Profile { p.mu.Lock() defer p.mu.Unlock() return p.profile } // Status devuelve el estado físico actual. func (p *Printer) Status() Status { p.mu.Lock() defer p.mu.Unlock() return p.status } // NewSession abre un canal de entrada. reply recibe las respuestas que la // impresora envía al host (DLE EOT, GS I...). Puede ser nil. func (p *Printer) NewSession(name string, reply func([]byte)) *Session { p.mu.Lock() s := &Session{p: p, name: name, reply: reply} p.sessions = append(p.sessions, s) p.event("Conexión abierta: %s", name) p.unlockAndFlush() return s } // Write entrega bytes recibidos por el canal. func (s *Session) Write(data []byte) { if len(data) == 0 { return } p := s.p p.mu.Lock() if len(p.capture)+len(data) <= maxCapture { p.capture = append(p.capture, data...) } if p.status.Blocked() { data = p.realtimeWhileBlocked(s, data) if len(data) > 0 { p.warn(s, "Impresora detenida (%s): %d bytes guardados en el buffer", p.status.describe(), len(data)) } } s.pending = append(s.pending, data...) p.process(s) p.unlockAndFlush() } // Close cierra el canal. Si quedan datos en el buffer se imprimirán cuando // la impresora vuelva a estar operativa, igual que en una impresora real. func (s *Session) Close() { p := s.p p.mu.Lock() s.closed = true s.reply = nil if p.asbTo == s { p.asbTo, p.asbMask = nil, 0 } p.event("Conexión cerrada: %s", s.name) p.dropFinishedSessions() p.unlockAndFlush() } // UpdateStatus modifica las condiciones físicas simuladas. func (p *Printer) UpdateStatus(fn func(*Status)) { p.mu.Lock() old := p.status fn(&p.status) if old != p.status { p.event("Estado: %s", p.status.describe()) p.sendASB() if old.Blocked() && !p.status.Blocked() { p.resume() } } p.unlockAndFlush() } // PowerCycle simula apagar y encender: se pierde el buffer y el modo. func (p *Printer) PowerCycle() { p.mu.Lock() for _, s := range p.sessions { s.pending = nil } p.dropFinishedSessions() p.st = defaultMode(p.profile.WidthDots) p.line = lineBuf{} p.asbTo, p.asbMask = nil, 0 p.event("Impresora reiniciada (apagar/encender): buffer y modos borrados") p.unlockAndFlush() } // TearPaper arranca a mano todo el papel impreso que no se ha cortado. func (p *Printer) TearPaper() { p.mu.Lock() if p.paper.height > 0 { p.finishTicket(p.paper.height, "arrancado") p.event("Papel arrancado a mano") } p.unlockAndFlush() } // ClearTickets borra los tickets ya cortados de la vista. func (p *Printer) ClearTickets() { p.mu.Lock() p.tickets = nil p.unlockAndFlush() } // Tickets devuelve los tickets cortados. func (p *Printer) Tickets() []*Ticket { p.mu.Lock() defer p.mu.Unlock() return append([]*Ticket(nil), p.tickets...) } // Capture devuelve una copia de todos los bytes recibidos. func (p *Printer) Capture() []byte { p.mu.Lock() defer p.mu.Unlock() return append([]byte(nil), p.capture...) } // ClearCapture vacía la captura de bytes. func (p *Printer) ClearCapture() { p.mu.Lock() p.capture = nil p.unlockAndFlush() } // Note añade un mensaje informativo al registro. func (p *Printer) Note(format string, args ...any) { p.mu.Lock() p.event(format, args...) p.unlockAndFlush() } // Snapshot es una foto del estado para pintar la interfaz. type Snapshot struct { Profile Profile Status Status Tickets []*Ticket Paper *image.Gray // papel aún dentro de la impresora (puede ser nil) CutterRow int // fila de Paper donde está la cuchilla (-1 si no se ve) Log []LogEntry Connections []string PendingBytes int CaptureBytes int Mode string } // Snapshot devuelve el estado actual con como mucho maxLog entradas de registro. func (p *Printer) Snapshot(maxLog int) Snapshot { p.mu.Lock() defer p.mu.Unlock() snap := Snapshot{ Profile: p.profile, Status: p.status, Tickets: append([]*Ticket(nil), p.tickets...), CaptureBytes: len(p.capture), Mode: p.st.describe(), CutterRow: -1, } if p.paper.height > 0 { if p.curImgVer != p.paper.version { p.curImg = p.paper.image(0, p.paper.height) p.curImgVer = p.paper.version } snap.Paper = p.curImg if r := p.paper.height - p.profile.CutterOffset; r > 0 { snap.CutterRow = r } } start := len(p.logs) - maxLog if start < 0 { start = 0 } snap.Log = append([]LogEntry(nil), p.logs[start:]...) for _, s := range p.sessions { snap.PendingBytes += len(s.pending) if !s.closed { snap.Connections = append(snap.Connections, s.name) } } return snap } // process interpreta todo lo que se pueda del buffer de la sesión. func (p *Printer) process(s *Session) { for len(s.pending) > 0 && !p.status.Blocked() { n := p.step(s, s.pending) if n == 0 { return // comando incompleto: esperamos más bytes } s.pending = s.pending[n:] } if len(s.pending) == 0 { s.pending = nil } } func (p *Printer) resume() { for _, s := range p.sessions { if len(s.pending) > 0 { p.event("Reanudando: %d bytes pendientes de %s", len(s.pending), s.name) p.process(s) } } p.dropFinishedSessions() } func (p *Printer) dropFinishedSessions() { kept := p.sessions[:0] for _, s := range p.sessions { if !s.closed || len(s.pending) > 0 { kept = append(kept, s) } } for i := len(kept); i < len(p.sessions); i++ { p.sessions[i] = nil } p.sessions = kept } // unlockAndFlush libera el cerrojo y después entrega las respuestas y // avisos pendientes, para no llamar a código externo con el cerrojo cogido. func (p *Printer) unlockAndFlush() { type delivery struct { fn func([]byte) data []byte } var out []delivery for _, s := range p.sessions { if len(s.outbox) > 0 && s.reply != nil { out = append(out, delivery{s.reply, s.outbox}) } s.outbox = nil } tix := p.newTix p.newTix = nil onChange, onTicket := p.onChange, p.onTicket p.mu.Unlock() for _, d := range out { d.fn(d.data) } if onTicket != nil { for _, t := range tix { onTicket(t) } } if onChange != nil { onChange() } } // respond encola bytes de respuesta hacia el host de la sesión. func (p *Printer) respond(s *Session, data ...byte) { if s == nil || s.closed { return } s.outbox = append(s.outbox, data...) p.addLog(s, LogResponse, data, "→ respuesta al host: "+hexString(data, 16)) } func (p *Printer) addLog(s *Session, kind LogKind, raw []byte, text string) { p.logSeq++ src := "—" if s != nil { src = s.name } e := LogEntry{Seq: p.logSeq, Time: time.Now(), Source: src, Kind: kind, Hex: hexString(raw, 24), Text: text} p.logs = append(p.logs, e) if len(p.logs) > maxLogEntries { p.logs = append([]LogEntry(nil), p.logs[len(p.logs)-maxLogEntries*2/3:]...) } if p.logOut != nil { fmt.Fprintf(p.logOut, "%s %-22s %-40s %s\n", e.Time.Format("15:04:05.000"), e.Source, e.Hex, e.Text) } } func (p *Printer) cmd(s *Session, raw []byte, format string, args ...any) { p.addLog(s, LogCommand, raw, fmt.Sprintf(format, args...)) } func (p *Printer) warn(s *Session, format string, args ...any) { p.addLog(s, LogWarning, nil, fmt.Sprintf(format, args...)) } func (p *Printer) warnRaw(s *Session, raw []byte, format string, args ...any) { p.addLog(s, LogWarning, raw, fmt.Sprintf(format, args...)) } func (p *Printer) event(format string, args ...any) { p.addLog(nil, LogEvent, nil, fmt.Sprintf(format, args...)) } func hexString(b []byte, max int) string { if len(b) == 0 { return "" } var sb strings.Builder for i, c := range b { if i == max { fmt.Fprintf(&sb, "… (%d bytes)", len(b)) break } if i > 0 { sb.WriteByte(' ') } fmt.Fprintf(&sb, "%02X", c) } return sb.String() } func onOff(v bool) string { if v { return "activada" } return "desactivada" }