539 lines
13 KiB
Go
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"
|
|
}
|