Files
printer-driver/emulator/internal/escpos/printer.go
T
2026-09-30 10:22:42 +02:00

539 lines
13 KiB
Go

// 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"
}