initial commit

This commit is contained in:
2026-09-30 10:22:42 +02:00
commit 3b0f5e365f
32 changed files with 4498 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
# Emulador de impresora térmica ESC/POS
Impresora térmica virtual para probar la librería sin papel ni ruido. Recibe
bytes ESC/POS por **LAN** (TCP RAW, puerto 9100) y por **USB** (dispositivo
USB real exportado por USB/IP), dibuja el papel que saldría y responde a los
comandos de estado como una impresora de verdad.
## Compilar y ejecutar
```sh
cd emulator
go build -o escpos-emu.exe .
./escpos-emu.exe # perfil 80 mm, LAN :9100, USB/IP :3240
./escpos-emu.exe -perfil 58mm # clon barato: sin cuchilla, sin QR, GS I limitado
./escpos-emu.exe -nogui # sin ventana: registro por consola, tickets a ./tickets/*.png
```
| Opción | Por defecto | Significado |
|-------------|-------------|-------------------------------------------------------|
| `-lan` | `:9100` | Puerto TCP RAW. Vacío (`-lan ""`) lo desactiva |
| `-usbip` | `:3240` | Servidor USB/IP. Vacío lo desactiva |
| `-perfil` | `80mm` | `80mm` o `58mm` |
| `-nogui` | no | Modo consola |
| `-tickets` | — | Carpeta donde guardar cada ticket cortado como PNG |
La primera vez, el firewall de Windows preguntará si permite las conexiones:
acéptalo en redes privadas si vas a conectar desde WSL u otra máquina.
## La ventana
- **Centro**: el papel. Arriba, los tickets ya cortados; abajo, el papel que
sigue dentro de la impresora. La línea roja es la cuchilla.
- **Derecha**: cada comando recibido con sus bytes en hexadecimal y su
significado. Verde: texto. Azul: respuestas al host. Naranja: avisos.
- **Izquierda**: interruptores de estado físico (tapa, papel, cuchilla,
offline), cajón, captura de bytes a `.bin` y reinicio.
## LAN
Cualquier cliente TCP sirve. Abre una conexión a `IP:9100`, escribe los bytes
y, si has enviado un comando de estado, lee la respuesta por el mismo socket.
Desde WSL con NAT, la IP de Windows es la puerta de enlace:
`ip route show default | awk '{print $3}'`.
## USB (USB/IP)
El emulador actúa como servidor USB/IP y publica un dispositivo `1209:0001`
de clase impresora (07/01/02, bidireccional) con endpoint bulk OUT 0x01 y bulk
IN 0x82, de 64 bytes. El sistema operativo lo ve como una impresora USB
enchufada físicamente y carga su driver habitual.
**Fedora / Debian (WSL o nativo)**
```sh
sudo dnf install usbip # Debian: sudo apt install usbip
sudo modprobe vhci-hcd
usbip list -r <IP-de-Windows>
sudo usbip attach -r <IP-de-Windows> -b 1-1
lsusb # 1209:0001 EMULADOR ESCPOS-EMU-80
ls /dev/usb/lp* # el driver usblp crea /dev/usb/lp0
sudo usbip detach -p 0
```
**Windows**
Instala el cliente [usbip-win2](https://github.com/vadimgrn/usbip-win2) (lee
en su README los requisitos de firma del driver). Después:
```bat
usbip attach -r 127.0.0.1 -b 1-1
usbip port
usbip detach -p <puerto>
```
Windows carga `usbprint.sys` y crea un puerto `USB001`, igual que con una
impresora real. Para acceder con WinUSB o libusb hay que cambiarle el driver
(por ejemplo con Zadig), como con cualquier dispositivo USB.
Si `usbipd-win` ya ocupa el puerto 3240, usa `-usbip :3241` y en el cliente
indica el puerto (`usbip --tcp-port 3241 ...` en Linux).
## Respuestas de estado
| Comando | Respuesta (1 byte salvo indicación) |
|---------------|-------------------------------------------------------------------------|
| `DLE EOT 1` | `0x12` + bit 2 cajón abierto + bit 3 offline |
| `DLE EOT 2` | `0x12` + bit 2 tapa abierta + bit 5 sin papel + bit 6 error |
| `DLE EOT 3` | `0x12` + bit 3 error de cuchilla |
| `DLE EOT 4` | `0x12` + bits 2-3 papel casi agotado + bits 5-6 sin papel |
| `GS r 1` | bits 0-1 casi agotado, bits 2-3 sin papel |
| `GS I 1/2/3` | ID de modelo / tipo / versión |
| `GS I 65..69` | `0x5F` + texto + `0x00` (solo perfil 80mm; el 58mm no responde) |
| `GS a n` | activa el estado automático (4 bytes en cada cambio) |
Con la tapa abierta, sin papel, con error de cuchilla u offline, la impresora
**deja de imprimir** y guarda los datos en el buffer; `DLE EOT` y `DLE DC4 1`
se siguen atendiendo al momento. Al resolver el problema se imprime lo
pendiente.
## Comportamientos reales que conviene conocer
- **La cuchilla está 12 mm por encima del cabezal.** `GS V 0` corta donde
está la cuchilla, así que las últimas líneas quedan en el siguiente ticket.
`GS V 65 n` avanza hasta la cuchilla y `n` puntos más antes de cortar.
- **`ESC a` solo se aplica al principio de una línea.** Si llega a mitad de
línea, se aplica en la siguiente.
- **Los comandos pueden llegar partidos** entre paquetes TCP o transferencias
USB. El emulador espera a tener el comando completo, como el firmware.
- **Ancho imprimible**: 576 puntos en 80 mm y 384 en 58 mm (8 puntos/mm).
## Qué no emula
Modo página, códigos de barras (`GS k` dibuja un recuadro con los datos),
PDF417 y otros símbolos 2D distintos de QR, caracteres definidos por el
usuario, imágenes NV/descargables, impresión boca abajo, y control de flujo
(el buffer no se llena nunca).
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
module escpos-emulator
go 1.26.3
require (
gioui.org v0.10.3
golang.org/x/image v0.46.0
golang.org/x/text v0.42.0
rsc.io/qr v0.2.0
)
require (
gioui.org/shader v1.0.9 // indirect
github.com/go-text/typesetting v0.3.5 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.48.0 // indirect
)
+27
View File
@@ -0,0 +1,27 @@
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
gioui.org v0.10.3 h1:ZiJ4CRvmPQEl2Ee0lK6JQ9WepiA628YmbjibiBVnBTw=
gioui.org v0.10.3/go.mod h1:x8MAOooc/v4UUaB5o6BS/FpYdxnzh3LBvZlTBmYpnlQ=
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.9 h1:XxnqIfmClWpN49kizxH2W0JcCFrrEP4q3jZmNYaltbs=
gioui.org/shader v1.0.9/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=
github.com/go-text/typesetting v0.3.5/go.mod h1:XZO1hD+nQVyvVa5IicQk7FsCa4PFQaJ2soWAP1f//68=
github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2It5K75XkavhTiCKExUfVaVDS1feBnLCru5qeoY=
github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80=
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
+36
View File
@@ -0,0 +1,36 @@
package escpos
import (
"fmt"
"image/png"
"os"
"path/filepath"
"time"
)
// SaveTicketPNG guarda el ticket en dir y devuelve la ruta del fichero.
func SaveTicketPNG(dir string, t *Ticket) (string, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
path := filepath.Join(dir, fmt.Sprintf("ticket-%s-%04d.png", t.Time.Format("20060102-150405"), t.Seq))
f, err := os.Create(path)
if err != nil {
return "", err
}
if err := png.Encode(f, t.Image); err != nil {
f.Close()
return "", err
}
return path, f.Close()
}
// SaveCapture guarda todos los bytes recibidos en un .bin dentro de dir.
func (p *Printer) SaveCapture(dir string) (string, int, error) {
data := p.Capture()
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", 0, err
}
path := filepath.Join(dir, "captura-"+time.Now().Format("20060102-150405")+".bin")
return path, len(data), os.WriteFile(path, data, 0o644)
}
+777
View File
@@ -0,0 +1,777 @@
package escpos
import (
"bytes"
"strings"
)
const (
nul = 0x00
ht = 0x09
lf = 0x0A
ff = 0x0C
cr = 0x0D
dle = 0x10
can = 0x18
esc = 0x1B
fs = 0x1C
gs = 0x1D
)
// step interpreta el comando que empieza en b[0]. Devuelve los bytes
// consumidos, o 0 si el comando está incompleto y hay que esperar más datos.
func (p *Printer) step(s *Session, b []byte) int {
switch c := b[0]; {
case c >= 0x20:
return p.text(s, b)
case c == lf:
p.cmd(s, b[:1], "LF — imprime la línea y avanza %d puntos", max(p.st.lineSpacing, p.lineHeight()))
p.printLine(p.st.lineSpacing)
case c == cr:
p.cmd(s, b[:1], "CR — ignorado (esta impresora no hace retorno automático)")
case c == ht:
p.tab()
p.cmd(s, b[:1], "HT — tabulador: columna en punto %d", p.line.x)
case c == esc:
return p.escCommand(s, b)
case c == gs:
return p.gsCommand(s, b)
case c == dle:
return p.dleCommand(s, b)
case c == fs:
return p.fsCommand(s, b)
case c == ff || c == can:
p.cmd(s, b[:1], "0x%02X — solo tiene efecto en modo página: ignorado", c)
default:
p.warnRaw(s, b[:1], "Byte de control 0x%02X sin significado: ignorado", c)
}
return 1
}
func (p *Printer) text(s *Session, b []byte) int {
n := 0
for n < len(b) && b[n] >= 0x20 {
n++
}
var sb strings.Builder
for _, c := range b[:n] {
r := p.decode(c)
p.addChar(r)
sb.WriteRune(r)
}
p.addLog(s, LogText, b[:n], "Texto: «"+sb.String()+"»")
return n
}
// Longitud total de los comandos ESC de longitud fija.
var escFixedLen = map[byte]int{
'@': 2, 'E': 3, 'G': 3, '-': 3, 'a': 3, '!': 3, 'M': 3, 'd': 3, 'J': 3,
'2': 2, '3': 3, ' ': 3, 't': 3, 'R': 3, 'p': 5, 'i': 2, 'm': 2, '$': 4,
'\\': 4, '{': 3, 'V': 3, 'c': 4, '=': 3, 'v': 2, 'u': 3, 'U': 3, 'L': 2,
'S': 2, 'T': 3, 'W': 10, 'r': 3, 'e': 3, '%': 3, '?': 3, 'K': 3,
}
// Longitud total de los comandos GS de longitud fija.
var gsFixedLen = map[byte]int{
'!': 3, 'B': 3, 'L': 4, 'W': 4, 'I': 3, 'r': 3, 'a': 3, 'h': 3, 'w': 3,
'H': 3, 'f': 3, 'P': 4, 'b': 3, '/': 3, '$': 4, '\\': 4, 'T': 3, 'E': 3,
':': 2, '^': 5, 'j': 3, 'g': 3, 'z': 3,
}
func (p *Printer) escCommand(s *Session, b []byte) int {
if len(b) < 2 {
return 0
}
fixed := escFixedLen
if n, ok := fixed[b[1]]; ok && len(b) < n {
return 0
}
switch b[1] {
case '@':
p.st = defaultMode(p.profile.WidthDots)
p.line = lineBuf{}
p.cmd(s, b[:2], "ESC @ — inicializar: se borran el buffer de línea y todos los modos")
return 2
case 'E':
p.st.bold = b[2]&1 == 1
p.cmd(s, b[:3], "ESC E %d — negrita %s", b[2], onOff(p.st.bold))
return 3
case 'G':
p.st.doubleStrike = b[2]&1 == 1
p.cmd(s, b[:3], "ESC G %d — doble impacto %s", b[2], onOff(p.st.doubleStrike))
return 3
case '-':
switch b[2] {
case 0, '0':
p.st.underline = 0
case 1, '1':
p.st.underline = 1
case 2, '2':
p.st.underline = 2
}
p.cmd(s, b[:3], "ESC - %d — subrayado de %d puntos", b[2], p.st.underline)
return 3
case 'a':
switch b[2] {
case 0, '0':
p.st.align = 0
case 1, '1':
p.st.align = 1
case 2, '2':
p.st.align = 2
}
msg := "ESC a %d — alineación: %s"
if len(p.line.cells) > 0 {
msg += " (a mitad de línea: se aplicará en la siguiente)"
}
p.cmd(s, b[:3], msg, b[2], []string{"izquierda", "centro", "derecha"}[p.st.align])
return 3
case '!':
n := b[2]
p.st.fontB = n&0x01 != 0
p.st.bold = n&0x08 != 0
p.st.heightMul = 1 + int(n>>4&1)
p.st.widthMul = 1 + int(n>>5&1)
p.st.underline = int(n >> 7 & 1)
p.cmd(s, b[:3], "ESC ! 0x%02X — modo: fuente %s, negrita %v, alto×%d, ancho×%d, subrayado %v",
n, map[bool]string{false: "A", true: "B"}[p.st.fontB], p.st.bold, p.st.heightMul, p.st.widthMul, p.st.underline > 0)
return 3
case 'M':
p.st.fontB = b[2] == 1 || b[2] == '1'
p.cmd(s, b[:3], "ESC M %d — fuente %s", b[2], map[bool]string{false: "A (12×24)", true: "B (9×17)"}[p.st.fontB])
return 3
case 'd':
n := int(b[2])
p.cmd(s, b[:3], "ESC d %d — imprime y avanza %d líneas", n, n)
if n == 0 {
p.printLine(0)
} else {
p.printLine(p.st.lineSpacing)
p.paper.grow((n - 1) * p.st.lineSpacing)
}
return 3
case 'J':
p.cmd(s, b[:3], "ESC J %d — imprime y avanza %d puntos (%.1f mm)", b[2], b[2], float64(b[2])/8)
p.printLine(int(b[2]))
return 3
case '2':
p.st.lineSpacing = 30
p.cmd(s, b[:2], "ESC 2 — interlineado por defecto (30 puntos)")
return 2
case '3':
p.st.lineSpacing = int(b[2])
p.cmd(s, b[:3], "ESC 3 %d — interlineado de %d puntos", b[2], b[2])
return 3
case ' ':
p.st.rightSpacing = int(b[2])
p.cmd(s, b[:3], "ESC SP %d — espacio a la derecha de cada carácter: %d puntos", b[2], b[2])
return 3
case 't':
if cp, ok := codePages[b[2]]; ok {
p.st.codePage = b[2]
p.cmd(s, b[:3], "ESC t %d — página de códigos %s", b[2], cp.name)
} else {
p.warnRaw(s, b[:3], "ESC t %d — página de códigos no soportada por el emulador: se mantiene la actual", b[2])
}
return 3
case 'R':
p.st.intl = b[2]
name, ok := intlNames[b[2]]
if !ok {
name = "sin tabla en el emulador: se usa EE.UU."
}
p.cmd(s, b[:3], "ESC R %d — juego de caracteres internacional: %s", b[2], name)
return 3
case 'p':
p.status.DrawerOpen = true
p.sendASB()
p.cmd(s, b[:5], "ESC p %d %d %d — pulso al cajón (pin %d): encendido %d ms, apagado %d ms → CAJÓN ABIERTO",
b[2], b[3], b[4], map[bool]int{false: 2, true: 5}[b[2]&1 == 1], int(b[3])*2, int(b[4])*2)
return 5
case 'i', 'm':
p.cmd(s, b[:2], "ESC %c — corte %s (comando antiguo)", b[1], map[byte]string{'i': "total", 'm': "parcial"}[b[1]])
if b[1] == 'i' {
p.doCut(s, "total", 0)
} else {
p.doCut(s, "parcial", 0)
}
return 2
case '$':
pos := int(b[2]) | int(b[3])<<8
if pos <= p.areaWidth() {
p.line.x = pos
}
p.cmd(s, b[:4], "ESC $ — posición absoluta: punto %d", pos)
return 4
case '\\':
d := int(int16(uint16(b[2]) | uint16(b[3])<<8))
if x := p.line.x + d; x >= 0 && x <= p.areaWidth() {
p.line.x = x
}
p.cmd(s, b[:4], "ESC \\ — posición relativa: %+d puntos", d)
return 4
case '*':
if len(b) < 5 {
return 0
}
m := b[2]
k := int(b[3]) | int(b[4])<<8
bpc := 1
if m == 32 || m == 33 {
bpc = 3
}
total := 5 + k*bpc
if len(b) < total {
return 0
}
p.addBitImage(m, k, b[5:total])
p.cmd(s, b[:total], "ESC * %d — imagen de bits: %d columnas de %d puntos", m, k, 8*bpc)
return total
case 'D':
var tabs []int
for i := 2; i < len(b); i++ {
if b[i] == nul || len(tabs) == 32 {
n := i
if b[i] == nul {
n = i + 1
}
p.st.tabs = tabs
p.cmd(s, b[:n], "ESC D — tabuladores en las columnas %v", tabs)
return n
}
tabs = append(tabs, int(b[i]))
}
return 0
case '&':
if len(b) < 5 {
return 0
}
y, i := int(b[2]), 5
for c := int(b[3]); c <= int(b[4]); c++ {
if len(b) <= i {
return 0
}
i += 1 + y*int(b[i])
if len(b) < i {
return 0
}
}
p.warnRaw(s, b[:i], "ESC & — definir caracteres de usuario: no emulado")
return i
case '(':
if len(b) < 5 {
return 0
}
total := 5 + (int(b[3]) | int(b[4])<<8)
if len(b) < total {
return 0
}
p.warnRaw(s, b[:total], "ESC ( %c — no emulado", b[2])
return total
case '{':
p.st.upsideDown = b[2]&1 == 1
p.warnRaw(s, b[:3], "ESC { %d — impresión boca abajo: no emulada", b[2])
return 3
case 'v':
p.cmd(s, b[:2], "ESC v — enviar estado del sensor de papel")
p.respond(s, p.paperSensorByte())
return 2
case 'u':
p.cmd(s, b[:3], "ESC u %d — enviar estado del periférico (cajón)", b[2])
p.respond(s, p.drawerByte())
return 3
case 'L', 'S', 'T', 'W':
n := map[byte]int{'L': 2, 'S': 2, 'T': 3, 'W': 10}[b[1]]
p.warnRaw(s, b[:n], "ESC %c — comando de modo página: no emulado", b[1])
return n
}
if n, ok := fixed[b[1]]; ok {
p.warnRaw(s, b[:n], "ESC %c — sin efecto en el emulador", b[1])
return n
}
p.warnRaw(s, b[:2], "ESC 0x%02X — comando desconocido", b[1])
return 2
}
func (p *Printer) gsCommand(s *Session, b []byte) int {
if len(b) < 2 {
return 0
}
fixed := gsFixedLen
if n, ok := fixed[b[1]]; ok && len(b) < n {
return 0
}
switch b[1] {
case '!':
n := b[2]
p.st.widthMul = 1 + int(n>>4&7)
p.st.heightMul = 1 + int(n&7)
p.cmd(s, b[:3], "GS ! 0x%02X — tamaño: ancho ×%d, alto ×%d", n, p.st.widthMul, p.st.heightMul)
return 3
case 'B':
p.st.reverse = b[2]&1 == 1
p.cmd(s, b[:3], "GS B %d — blanco sobre negro %s", b[2], onOff(p.st.reverse))
return 3
case 'L':
p.st.leftMargin = int(b[2]) | int(b[3])<<8
p.cmd(s, b[:4], "GS L — margen izquierdo de %d puntos", p.st.leftMargin)
return 4
case 'W':
p.st.areaWidth = int(b[2]) | int(b[3])<<8
p.cmd(s, b[:4], "GS W — ancho del área de impresión: %d puntos", p.st.areaWidth)
return 4
case 'V':
return p.gsCut(s, b)
case 'v':
if len(b) < 3 {
return 0
}
if b[2] != '0' {
p.warnRaw(s, b[:3], "GS v %c — desconocido", b[2])
return 3
}
if len(b) < 8 {
return 0
}
x := int(b[4]) | int(b[5])<<8
y := int(b[6]) | int(b[7])<<8
total := 8 + x*y
if len(b) < total {
return 0
}
p.printRaster(b[3], x, y, b[8:total])
p.cmd(s, b[:total], "GS v 0 %d — imagen raster de %d×%d puntos (%d bytes de datos)", b[3], x*8, y, x*y)
return total
case '(':
if len(b) < 5 {
return 0
}
total := 5 + (int(b[3]) | int(b[4])<<8)
if len(b) < total {
return 0
}
if b[2] == 'k' {
p.gsParenK(s, b[:total])
} else {
p.warnRaw(s, b[:total], "GS ( %c — no emulado", b[2])
}
return total
case '8':
if len(b) < 7 {
return 0
}
n := int64(b[3]) | int64(b[4])<<8 | int64(b[5])<<16 | int64(b[6])<<24
if n > 8<<20 {
p.warnRaw(s, b[:7], "GS 8 %c — longitud absurda (%d): se descarta la cabecera", b[2], n)
return 7
}
total := 7 + int(n)
if len(b) < total {
return 0
}
p.warnRaw(s, b[:total], "GS 8 %c — gráficos extendidos: no emulado", b[2])
return total
case '*':
if len(b) < 4 {
return 0
}
total := 4 + int(b[2])*int(b[3])*8
if len(b) < total {
return 0
}
p.warnRaw(s, b[:total], "GS * — imagen descargable: no emulada")
return total
case 'I':
p.cmd(s, b[:3], "GS I %d — identificación de la impresora", b[2])
p.gsI(s, b[2])
return 3
case 'r':
switch b[2] {
case 1, '1':
p.cmd(s, b[:3], "GS r %d — enviar estado del sensor de papel", b[2])
p.respond(s, p.paperSensorByte())
case 2, '2':
p.cmd(s, b[:3], "GS r %d — enviar estado del cajón", b[2])
p.respond(s, p.drawerByte())
default:
p.warnRaw(s, b[:3], "GS r %d — tipo de estado desconocido", b[2])
}
return 3
case 'a':
p.asbMask = b[2]
p.asbTo = s
if b[2] == 0 {
p.asbTo = nil
}
p.cmd(s, b[:3], "GS a 0x%02X — estado automático (ASB) %s", b[2], onOff(b[2] != 0))
p.sendASB()
return 3
case 'k':
return p.gsBarcode(s, b)
case 'h':
p.st.barcodeHeight = max(1, int(b[2]))
p.cmd(s, b[:3], "GS h %d — altura del código de barras", b[2])
return 3
case 'w':
p.st.barcodeWidth = int(b[2])
p.cmd(s, b[:3], "GS w %d — ancho de módulo del código de barras", b[2])
return 3
case 'H':
p.st.hriPos = b[2]
p.cmd(s, b[:3], "GS H %d — posición del texto legible (HRI)", b[2])
return 3
case 'f':
p.cmd(s, b[:3], "GS f %d — fuente del texto legible (HRI)", b[2])
return 3
}
if n, ok := fixed[b[1]]; ok {
p.warnRaw(s, b[:n], "GS %c — sin efecto en el emulador", b[1])
return n
}
p.warnRaw(s, b[:2], "GS 0x%02X — comando desconocido", b[1])
return 2
}
func (p *Printer) gsCut(s *Session, b []byte) int {
if len(b) < 3 {
return 0
}
m := b[2]
n, feed := 3, 0
kind := ""
switch m {
case 0, '0':
kind = "total"
case 1, '1':
kind = "parcial"
case 65, 66, 97, 98, 103, 104:
if len(b) < 4 {
return 0
}
n = 4
kind = map[bool]string{true: "total", false: "parcial"}[m%2 == 1]
feed = p.profile.CutterOffset + int(b[3])
default:
p.warnRaw(s, b[:3], "GS V %d — modo de corte desconocido", m)
return 3
}
if n == 4 {
p.cmd(s, b[:n], "GS V %d %d — avanza hasta la cuchilla + %d puntos y corte %s", m, b[3], b[3], kind)
} else {
p.cmd(s, b[:n], "GS V %d — corte %s en la posición actual", m, kind)
}
p.doCut(s, kind, feed)
return n
}
func (p *Printer) doCut(s *Session, kind string, feed int) {
if !p.profile.HasCutter {
p.flushLine()
p.paper.grow(feed)
p.warn(s, "Este modelo no tiene cuchilla: el corte se ignora (usa «Arrancar papel»)")
return
}
if p.status.CutterError {
p.warn(s, "Error de cuchilla: no se corta")
return
}
p.cut(s, kind, feed)
}
// gsParenK procesa GS ( k pL pH cn fn [parámetros]: símbolos 2D.
func (p *Printer) gsParenK(s *Session, b []byte) {
if len(b) < 7 {
p.warnRaw(s, b, "GS ( k — demasiado corto")
return
}
cn, fn := b[5], b[6]
if cn != 49 {
p.warnRaw(s, b, "GS ( k cn=%d — símbolo 2D no emulado (solo QR, cn=49)", cn)
return
}
if !p.profile.NativeQR {
p.warnRaw(s, b, "GS ( k — este modelo no soporta QR nativo: comando ignorado")
return
}
switch fn {
case 65:
p.cmd(s, b, "GS ( k QR fn 65 — seleccionar modelo (siempre se usa el modelo 2)")
case 67:
if len(b) >= 8 && b[7] >= 1 && b[7] <= 16 {
p.st.qrModule = b[7]
}
p.cmd(s, b, "GS ( k QR fn 67 — tamaño de módulo: %d puntos", p.st.qrModule)
case 69:
if len(b) >= 8 && b[7] >= 48 && b[7] <= 51 {
p.st.qrECC = b[7]
}
p.cmd(s, b, "GS ( k QR fn 69 — corrección de errores %c", "LMQH"[p.st.qrECC-48])
case 80:
if len(b) >= 8 {
p.st.qrData = append([]byte(nil), b[8:]...)
}
p.cmd(s, b, "GS ( k QR fn 80 — almacenar %d bytes: «%s»", len(p.st.qrData), strings.ToValidUTF8(string(p.st.qrData), "?"))
case 81:
p.cmd(s, b, "GS ( k QR fn 81 — imprimir el QR almacenado")
p.printQR(s)
default:
p.warnRaw(s, b, "GS ( k QR fn %d — no emulado", fn)
}
}
func (p *Printer) gsBarcode(s *Session, b []byte) int {
if len(b) < 3 {
return 0
}
m := b[2]
var data []byte
total := 0
switch {
case m <= 6:
i := bytes.IndexByte(b[3:], nul)
if i < 0 {
if len(b) > 3+255 {
p.warnRaw(s, b[:3], "GS k %d — datos sin NUL final: se descarta", m)
return 3
}
return 0
}
data, total = b[3:3+i], 3+i+1
case m >= 65 && m <= 79:
if len(b) < 4 {
return 0
}
total = 4 + int(b[3])
if len(b) < total {
return 0
}
data = b[4:total]
default:
p.warnRaw(s, b[:3], "GS k %d — tipo de código de barras desconocido", m)
return 3
}
p.warnRaw(s, b[:total], "GS k %d — código de barras %s: se dibuja un marcador (no emulado)", m, barcodeNames[m])
p.printBarcode(m, data)
return total
}
func (p *Printer) gsI(s *Session, n byte) {
pr := p.profile
switch n {
case 1, 49:
p.respond(s, pr.ModelID)
return
case 2, 50:
p.respond(s, pr.TypeID)
return
case 3, 51:
p.respond(s, pr.VersionID)
return
}
info := map[byte]string{65: pr.Firmware, 66: pr.Maker, 67: pr.Model, 68: pr.Serial, 69: "ANK"}[n]
if info == "" || !pr.ExtendedGSI {
p.warn(s, "GS I %d — este modelo no responde (el host se quedará esperando hasta su timeout)", n)
return
}
resp := append([]byte{0x5F}, info...)
p.respond(s, append(resp, nul)...)
}
func (p *Printer) dleCommand(s *Session, b []byte) int {
if len(b) < 2 {
return 0
}
switch b[1] {
case 0x04:
if len(b) < 3 {
return 0
}
if b[2] == 7 || b[2] == 8 {
if len(b) < 4 {
return 0
}
p.warnRaw(s, b[:4], "DLE EOT %d — no emulado", b[2])
return 4
}
p.dleEOT(s, b[:3])
return 3
case 0x05:
if len(b) < 3 {
return 0
}
p.cmd(s, b[:3], "DLE ENQ %d — petición en tiempo real: sin efecto", b[2])
return 3
case 0x14:
if len(b) < 3 {
return 0
}
n := map[byte]int{1: 5, 2: 5, 7: 4, 8: 10}[b[2]]
if n == 0 {
n = 3
}
if len(b) < n {
return 0
}
p.dleDC4(s, b[:n])
return n
}
p.warnRaw(s, b[:1], "DLE suelto: ignorado")
return 1
}
var eotNames = map[byte]string{1: "estado de la impresora", 2: "causa de offline", 3: "causa del error", 4: "sensor de papel"}
func (p *Printer) dleEOT(s *Session, b []byte) {
n := b[2]
st := p.status
r := byte(0x12) // bits 1 y 4 fijos a 1
switch n {
case 1:
if st.DrawerOpen {
r |= 0x04
}
if st.Blocked() {
r |= 0x08
}
case 2:
if st.CoverOpen {
r |= 0x04
}
if st.PaperEnd {
r |= 0x20
}
if st.CoverOpen || st.PaperEnd || st.CutterError {
r |= 0x40
}
case 3:
if st.CutterError {
r |= 0x08
}
case 4:
if st.PaperNearEnd || st.PaperEnd {
r |= 0x0C
}
if st.PaperEnd {
r |= 0x60
}
default:
p.warnRaw(s, b, "DLE EOT %d — n no válido: sin respuesta", n)
return
}
p.cmd(s, b, "DLE EOT %d — tiempo real: %s", n, eotNames[n])
p.respond(s, r)
}
func (p *Printer) dleDC4(s *Session, b []byte) {
switch b[2] {
case 1:
p.status.DrawerOpen = true
p.sendASB()
p.cmd(s, b, "DLE DC4 1 — pulso al cajón en tiempo real → CAJÓN ABIERTO")
case 8:
for _, o := range p.sessions {
o.pending = nil
}
p.line = lineBuf{}
p.cmd(s, b, "DLE DC4 8 — borrar buffers")
default:
p.warnRaw(s, b, "DLE DC4 %d — no emulado", b[2])
}
}
// realtimeWhileBlocked ejecuta DLE EOT y DLE DC4 1 aunque la impresora esté
// detenida (como hace el firmware real) y devuelve el resto de bytes.
func (p *Printer) realtimeWhileBlocked(s *Session, data []byte) []byte {
rest := make([]byte, 0, len(data))
for i := 0; i < len(data); {
if data[i] == dle && i+2 < len(data) {
if data[i+1] == 0x04 && data[i+2] >= 1 && data[i+2] <= 4 {
p.dleEOT(s, data[i:i+3])
i += 3
continue
}
if data[i+1] == 0x14 && data[i+2] == 1 && i+4 < len(data) {
p.dleDC4(s, data[i:i+5])
i += 5
continue
}
}
rest = append(rest, data[i])
i++
}
return rest
}
func (p *Printer) fsCommand(s *Session, b []byte) int {
if len(b) < 2 {
return 0
}
n := map[byte]int{'.': 2, '&': 2, 'C': 3, '!': 3, '-': 3, 'W': 3, 'S': 4, 'p': 4}[b[1]]
if b[1] == '(' {
if len(b) < 5 {
return 0
}
n = 5 + (int(b[3]) | int(b[4])<<8)
}
if n == 0 {
n = 2
}
if len(b) < n {
return 0
}
switch b[1] {
case '.', '&':
p.cmd(s, b[:n], "FS %c — modo kanji %s: sin efecto", b[1], map[byte]string{'.': "desactivado", '&': "activado"}[b[1]])
default:
p.warnRaw(s, b[:n], "FS %c — no emulado", b[1])
}
return n
}
func (p *Printer) paperSensorByte() byte {
var r byte
if p.status.PaperNearEnd || p.status.PaperEnd {
r |= 0x03
}
if p.status.PaperEnd {
r |= 0x0C
}
return r
}
func (p *Printer) drawerByte() byte {
if p.status.DrawerOpen {
return 0x01
}
return 0x00
}
// sendASB envía los 4 bytes de estado automático si el host lo activó con GS a.
func (p *Printer) sendASB() {
if p.asbTo == nil || p.asbMask == 0 {
return
}
st := p.status
b1 := byte(0x10)
if st.DrawerOpen {
b1 |= 0x04
}
if st.Blocked() {
b1 |= 0x08
}
if st.CoverOpen {
b1 |= 0x20
}
var b2 byte
if st.CutterError {
b2 |= 0x08 | 0x40
}
var b3 byte
if st.PaperNearEnd || st.PaperEnd {
b3 |= 0x03
}
if st.PaperEnd {
b3 |= 0x0C
}
p.respond(p.asbTo, b1, b2, b3, 0x00)
}
+538
View File
@@ -0,0 +1,538 @@
// 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"
}
+188
View File
@@ -0,0 +1,188 @@
package escpos
import (
"bytes"
"image/png"
"os"
"path/filepath"
"sync"
"testing"
)
type recorder struct {
mu sync.Mutex
buf []byte
}
func (r *recorder) reply(b []byte) {
r.mu.Lock()
r.buf = append(r.buf, b...)
r.mu.Unlock()
}
func (r *recorder) take() []byte {
r.mu.Lock()
defer r.mu.Unlock()
b := r.buf
r.buf = nil
return b
}
func newTestPrinter(t *testing.T, key string) *Printer {
t.Helper()
prof, ok := FindProfile(key)
if !ok {
t.Fatalf("perfil %q no existe", key)
}
p, err := NewPrinter(prof)
if err != nil {
t.Fatal(err)
}
return p
}
func blackPixels(p *Printer) int {
n := 0
for _, t := range p.Tickets() {
for _, v := range t.Image.Pix {
if v < 0x80 {
n++
}
}
}
return n
}
func TestTextAndCut(t *testing.T) {
p := newTestPrinter(t, "80mm")
s := p.NewSession("test", nil)
s.Write([]byte("\x1b@Hola mundo\n\x1dVA\x00"))
if got := len(p.Tickets()); got != 1 {
t.Fatalf("tickets = %d, want 1", got)
}
if blackPixels(p) == 0 {
t.Fatal("el ticket está en blanco")
}
}
func TestCutWithoutFeedLeavesTail(t *testing.T) {
p := newTestPrinter(t, "80mm")
s := p.NewSession("test", nil)
s.Write([]byte("linea\n\x1dV\x00"))
if got := len(p.Tickets()); got != 0 {
t.Fatalf("con una sola línea bajo la cuchilla no debe salir ticket, salen %d", got)
}
s.Write([]byte("\x1dVA\x00"))
if got := len(p.Tickets()); got != 1 {
t.Fatalf("tickets = %d, want 1", got)
}
}
func TestCommandSplitAcrossWrites(t *testing.T) {
p := newTestPrinter(t, "80mm")
s := p.NewSession("test", nil)
img := []byte{0x1d, 'v', '0', 0, 2, 0, 3, 0, 0xff, 0xff, 0x81, 0x81, 0xff, 0xff}
for _, c := range img {
s.Write([]byte{c})
}
s.Write([]byte("\x1dVA\x00"))
if blackPixels(p) != 16+4+16 {
t.Fatalf("píxeles negros = %d, want 36", blackPixels(p))
}
}
func TestRealtimeStatusWhileCoverOpen(t *testing.T) {
p := newTestPrinter(t, "80mm")
var r recorder
s := p.NewSession("test", r.reply)
s.Write([]byte{dle, 0x04, 1})
if got := r.take(); !bytes.Equal(got, []byte{0x12}) {
t.Fatalf("DLE EOT 1 online = % X, want 12", got)
}
p.UpdateStatus(func(st *Status) { st.CoverOpen = true })
s.Write([]byte("retenido\n"))
s.Write([]byte{dle, 0x04, 2})
if got := r.take(); !bytes.Equal(got, []byte{0x12 | 0x04 | 0x40}) {
t.Fatalf("DLE EOT 2 con tapa abierta = % X, want 56", got)
}
if p.Snapshot(0).PendingBytes == 0 {
t.Fatal("los datos deberían seguir en el buffer")
}
p.UpdateStatus(func(st *Status) { st.CoverOpen = false })
if p.Snapshot(0).PendingBytes != 0 {
t.Fatal("al cerrar la tapa se debería imprimir el buffer")
}
}
func TestGSI(t *testing.T) {
p := newTestPrinter(t, "80mm")
var r recorder
s := p.NewSession("test", r.reply)
s.Write([]byte{gs, 'I', 67})
if got, want := r.take(), []byte("_ESCPOS-EMU-80\x00"); !bytes.Equal(got, want) {
t.Fatalf("GS I 67 = %q, want %q", got, want)
}
p58 := newTestPrinter(t, "58mm")
s58 := p58.NewSession("test", r.reply)
s58.Write([]byte{gs, 'I', 67})
if got := r.take(); len(got) != 0 {
t.Fatalf("el perfil 58mm no debería responder a GS I 67, responde %q", got)
}
}
func TestQRAndDrawer(t *testing.T) {
p := newTestPrinter(t, "80mm")
var r recorder
s := p.NewSession("test", r.reply)
data := "https://example.com"
n := len(data) + 3
cmd := []byte{gs, '(', 'k', byte(n), byte(n >> 8), 49, 80, 48}
cmd = append(cmd, data...)
cmd = append(cmd, gs, '(', 'k', 3, 0, 49, 67, 4)
cmd = append(cmd, gs, '(', 'k', 3, 0, 49, 81, 48)
cmd = append(cmd, esc, 'p', 0, 25, 250)
cmd = append(cmd, dle, 0x04, 1)
cmd = append(cmd, gs, 'V', 'A', 0)
s.Write(cmd)
if blackPixels(p) < 500 {
t.Fatalf("el QR no parece impreso (%d píxeles negros)", blackPixels(p))
}
if got := r.take(); !bytes.Equal(got, []byte{0x16}) {
t.Fatalf("DLE EOT 1 con cajón abierto = % X, want 16", got)
}
}
// TestSampleReceipt genera un ticket de ejemplo. Con ESCPOS_PNG=dir se
// guarda como PNG para inspeccionarlo a ojo.
func TestSampleReceipt(t *testing.T) {
p := newTestPrinter(t, "80mm")
s := p.NewSession("test", nil)
var b bytes.Buffer
b.WriteString("\x1b@\x1bt\x13\x1ba\x01\x1d!\x11TIENDA\n\x1d!\x00")
b.WriteString("\x1bE\x01Negrita\x1bE\x00 normal \x1b-\x01subrayado\x1b-\x00\n")
b.WriteString("\x1ba\x00Ca\xa4a 1,50 \xd5\n") // PC858: ñ y €
b.WriteString("\x1dB\x01 INVERTIDO \x1dB\x00\x1bM\x01 fuente B\x1bM\x00\n")
b.WriteString("\x1ba\x02Total: 12,00\n\x1ba\x00")
b.WriteString("Col1\tCol2\tCol3\n")
b.WriteString("\x1dVB\x10")
s.Write(b.Bytes())
tix := p.Tickets()
if len(tix) != 1 {
t.Fatalf("tickets = %d, want 1", len(tix))
}
if dir := os.Getenv("ESCPOS_PNG"); dir != "" {
f, err := os.Create(filepath.Join(dir, "sample.png"))
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := png.Encode(f, tix[0].Image); err != nil {
t.Fatal(err)
}
}
}
+597
View File
@@ -0,0 +1,597 @@
package escpos
import (
"fmt"
"image"
"strings"
"time"
"golang.org/x/image/font"
"golang.org/x/image/font/gofont/gomono"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
"golang.org/x/text/encoding/charmap"
"rsc.io/qr"
)
// mode es el estado de impresión que modifican los comandos.
type mode struct {
fontB bool
bold bool
doubleStrike bool
underline int // 0, 1 o 2 puntos
widthMul int // 1..8
heightMul int // 1..8
reverse bool
upsideDown bool
align int // 0 izquierda, 1 centro, 2 derecha
lineSpacing int // puntos
rightSpacing int // puntos entre caracteres
codePage byte
intl byte
leftMargin int
areaWidth int
tabs []int // posiciones en columnas
qrModule byte
qrECC byte
qrData []byte
barcodeHeight int
barcodeWidth int
hriPos byte
}
func defaultMode(width int) mode {
tabs := make([]int, 0, 32)
for i := 8; i <= 256 && len(tabs) < 32; i += 8 {
tabs = append(tabs, i)
}
return mode{
widthMul: 1, heightMul: 1, lineSpacing: 30, areaWidth: width,
tabs: tabs, qrModule: 3, qrECC: 48, barcodeHeight: 162, barcodeWidth: 3,
}
}
func (m mode) describe() string {
parts := []string{"Fuente A"}
if m.fontB {
parts[0] = "Fuente B"
}
parts = append(parts, fmt.Sprintf("tamaño %d×%d", m.widthMul, m.heightMul))
if m.bold || m.doubleStrike {
parts = append(parts, "negrita")
}
if m.underline > 0 {
parts = append(parts, fmt.Sprintf("subrayado %d", m.underline))
}
if m.reverse {
parts = append(parts, "invertido")
}
parts = append(parts, []string{"izquierda", "centro", "derecha"}[m.align])
name := "desconocida"
if cp, ok := codePages[m.codePage]; ok {
name = cp.name
}
parts = append(parts, fmt.Sprintf("página %d (%s)", m.codePage, name))
if m.intl != 0 {
parts = append(parts, fmt.Sprintf("juego internacional %d", m.intl))
}
parts = append(parts, fmt.Sprintf("interlineado %d", m.lineSpacing))
return strings.Join(parts, " · ")
}
type codePage struct {
name string
cm *charmap.Charmap
}
// Números de tabla según ESC t n en impresoras Epson.
var codePages = map[byte]codePage{
0: {"PC437 EE.UU.", charmap.CodePage437},
2: {"PC850 Multilingüe", charmap.CodePage850},
3: {"PC860 Portugués", charmap.CodePage860},
4: {"PC863 Canadá-Francés", charmap.CodePage863},
5: {"PC865 Nórdico", charmap.CodePage865},
16: {"WPC1252", charmap.Windows1252},
17: {"PC866 Cirílico", charmap.CodePage866},
18: {"PC852 Latin 2", charmap.CodePage852},
19: {"PC858 Euro", charmap.CodePage858},
}
// Juegos internacionales (ESC R n): sustituyen algunos bytes ASCII.
var intlSets = map[byte]map[byte]rune{
7: {0x23: '₧', 0x40: '@', 0x5B: '¡', 0x5C: 'Ñ', 0x5D: '¿', 0x7B: '¨', 0x7C: 'ñ', 0x7D: '}'},
11: {0x40: 'á', 0x5B: '¡', 0x5C: 'Ñ', 0x5D: '¿', 0x5E: 'é', 0x60: 'ü', 0x7B: 'í', 0x7C: 'ñ', 0x7D: 'ó', 0x7E: 'ú'},
12: {0x40: 'á', 0x5B: '¡', 0x5C: 'Ñ', 0x5D: '¿', 0x5E: 'é', 0x7B: 'í', 0x7C: 'ñ', 0x7D: 'ó', 0x7E: 'ú'},
}
var intlNames = map[byte]string{0: "EE.UU.", 7: "España I", 11: "Latinoamérica", 12: "España II"}
func (p *Printer) decode(b byte) rune {
if b < 0x80 {
if r, ok := intlSets[p.st.intl][b]; ok {
return r
}
return rune(b)
}
cp, ok := codePages[p.st.codePage]
if !ok {
cp = codePages[0]
}
return cp.cm.DecodeByte(b)
}
// ---------------------------------------------------------------------------
// Papel: mapa de bits monocromo que crece hacia abajo, 1 byte por punto.
type paper struct {
width int
height int
pix []byte
version int
}
func newPaper(width int) *paper { return &paper{width: width} }
func (pp *paper) grow(h int) int {
start := pp.height
if h <= 0 {
return start
}
pp.pix = append(pp.pix, make([]byte, h*pp.width)...)
pp.height += h
pp.version++
return start
}
func (pp *paper) blit(x, y int, bits []byte, w, h int) {
for row := 0; row < h; row++ {
py := y + row
if py < 0 || py >= pp.height {
continue
}
for col := 0; col < w; col++ {
px := x + col
if px < 0 || px >= pp.width || bits[row*w+col] == 0 {
continue
}
pp.pix[py*pp.width+px] = 1
}
}
pp.version++
}
func (pp *paper) image(from, to int) *image.Gray {
img := image.NewGray(image.Rect(0, 0, pp.width, to-from))
src := pp.pix[from*pp.width : to*pp.width]
for i, v := range src {
if v != 0 {
img.Pix[i] = 0x22
} else {
img.Pix[i] = 0xFC
}
}
return img
}
// cut separa las filas [0, at) como ticket y deja el resto en la impresora.
func (pp *paper) cut(at int) *image.Gray {
img := pp.image(0, at)
rest := append([]byte(nil), pp.pix[at*pp.width:]...)
pp.pix = rest
pp.height -= at
pp.version++
return img
}
// ---------------------------------------------------------------------------
// Línea en curso: celdas (caracteres o imágenes de bits) pendientes de imprimir.
type cell struct {
x, w, h int
bits []byte
}
type lineBuf struct {
cells []cell
x int
align int
}
func (p *Printer) areaWidth() int {
w := p.st.areaWidth
if p.st.leftMargin+w > p.profile.WidthDots {
w = p.profile.WidthDots - p.st.leftMargin
}
if w < 0 {
w = 0
}
return w
}
func (p *Printer) sideMargin() int { return (p.profile.PaperDots - p.profile.WidthDots) / 2 }
func (p *Printer) alignOffset(align, used int) int {
free := p.areaWidth() - used
if free <= 0 {
return 0
}
switch align {
case 1:
return free / 2
case 2:
return free
}
return 0
}
func (p *Printer) placeCell(c cell, wrap bool) {
area := p.areaWidth()
if p.line.x+c.w > area && wrap && len(p.line.cells) > 0 {
p.printLine(p.st.lineSpacing)
}
if p.line.x+c.w > area {
c = c.cropWidth(area - p.line.x)
if c.w <= 0 {
return
}
}
if len(p.line.cells) == 0 {
// ESC a solo tiene efecto si se procesa al principio de la línea.
p.line.align = p.st.align
}
c.x = p.line.x
p.line.cells = append(p.line.cells, c)
p.line.x += c.w
}
func (c cell) cropWidth(w int) cell {
if w >= c.w {
return c
}
if w <= 0 {
return cell{}
}
bits := make([]byte, w*c.h)
for y := 0; y < c.h; y++ {
copy(bits[y*w:(y+1)*w], c.bits[y*c.w:y*c.w+w])
}
return cell{w: w, h: c.h, bits: bits}
}
func (p *Printer) lineHeight() int {
h := 0
for _, c := range p.line.cells {
if c.h > h {
h = c.h
}
}
return h
}
// printLine imprime la línea en curso y avanza como mínimo feed puntos.
func (p *Printer) printLine(feed int) {
lineH := p.lineHeight()
block := max(lineH, feed)
start := p.paper.grow(block)
if len(p.line.cells) > 0 {
x0 := p.sideMargin() + p.st.leftMargin + p.alignOffset(p.line.align, p.line.x)
for _, c := range p.line.cells {
p.paper.blit(x0+c.x, start+lineH-c.h, c.bits, c.w, c.h)
}
}
p.line = lineBuf{}
p.checkPaperLength()
}
func (p *Printer) flushLine() {
if len(p.line.cells) > 0 {
p.printLine(p.st.lineSpacing)
}
p.line = lineBuf{}
}
// printBlock imprime un bloque gráfico (raster, QR...) respetando ESC a.
func (p *Printer) printBlock(bits []byte, w, h int) {
c := cell{w: w, h: h, bits: bits}.cropWidth(p.areaWidth())
start := p.paper.grow(c.h)
x0 := p.sideMargin() + p.st.leftMargin + p.alignOffset(p.st.align, c.w)
p.paper.blit(x0, start, c.bits, c.w, c.h)
p.checkPaperLength()
}
func (p *Printer) checkPaperLength() {
if p.paper.height > maxPaperDots {
p.finishTicket(p.paper.height, "arrancado")
p.event("Más de %d mm sin cortar: el papel se arranca solo", maxPaperDots/8)
}
}
func (p *Printer) finishTicket(at int, kind string) {
p.ticketSeq++
t := &Ticket{Seq: p.ticketSeq, Time: time.Now(), Cut: kind, Image: p.paper.cut(at)}
p.tickets = append(p.tickets, t)
if len(p.tickets) > maxTickets {
p.tickets = append([]*Ticket(nil), p.tickets[len(p.tickets)-maxTickets:]...)
}
p.newTix = append(p.newTix, t)
}
// cut imprime lo pendiente, avanza feed puntos y corta por la cuchilla.
func (p *Printer) cut(s *Session, kind string, feed int) {
p.flushLine()
p.paper.grow(feed)
at := p.paper.height - p.profile.CutterOffset
if at <= 0 {
p.warn(s, "Corte sin papel impreso por encima de la cuchilla: no sale ningún ticket")
return
}
if p.profile.CutterOffset > 0 && feed < p.profile.CutterOffset {
p.warn(s, "La cuchilla está %d mm por encima del cabezal: las últimas %d filas se quedan para el siguiente ticket (usa GS V 65 n / 66 n para avanzar antes de cortar)",
p.profile.CutterOffset/8, p.profile.CutterOffset)
}
p.finishTicket(at, kind)
}
// ---------------------------------------------------------------------------
// Caracteres
type glyphFont struct {
w, h int
face font.Face
baseline int
cache map[rune][]byte
}
func loadFonts() (*glyphFont, *glyphFont, error) {
f, err := opentype.Parse(gomono.TTF)
if err != nil {
return nil, nil, err
}
// Go Mono avanza 0,6 em: 20 px -> 12 puntos (fuente A 12×24), 15 px -> 9 (fuente B 9×17).
a, err := newGlyphFont(f, 20, 12, 24)
if err != nil {
return nil, nil, err
}
b, err := newGlyphFont(f, 15, 9, 17)
if err != nil {
return nil, nil, err
}
return a, b, nil
}
func newGlyphFont(f *opentype.Font, size float64, w, h int) (*glyphFont, error) {
face, err := opentype.NewFace(f, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull})
if err != nil {
return nil, err
}
m := face.Metrics()
asc, desc := m.Ascent.Ceil(), m.Descent.Ceil()
return &glyphFont{w: w, h: h, face: face, baseline: asc + (h-(asc+desc))/2, cache: map[rune][]byte{}}, nil
}
func (g *glyphFont) glyph(r rune) []byte {
if bits, ok := g.cache[r]; ok {
return bits
}
img := image.NewAlpha(image.Rect(0, 0, g.w, g.h))
x := fixed.I(0)
if adv, ok := g.face.GlyphAdvance(r); ok {
x = (fixed.I(g.w) - adv) / 2
}
d := font.Drawer{Dst: img, Src: image.Opaque, Face: g.face, Dot: fixed.Point26_6{X: x, Y: fixed.I(g.baseline)}}
d.DrawString(string(r))
bits := make([]byte, g.w*g.h)
for i, a := range img.Pix {
if a >= 0x80 {
bits[i] = 1
}
}
g.cache[r] = bits
return bits
}
func (p *Printer) currentFont() *glyphFont {
if p.st.fontB {
return p.fonts[1]
}
return p.fonts[0]
}
// charCell dibuja un carácter con los atributos actuales, como haría el
// firmware: glifo de mapa de bits ampliado punto a punto.
func (p *Printer) charCell(r rune) cell {
f := p.currentFont()
g := f.glyph(r)
mw, mh := p.st.widthMul, p.st.heightMul
bold := p.st.bold || p.st.doubleStrike
w := (f.w + p.st.rightSpacing) * mw
h := f.h * mh
bits := make([]byte, w*h)
for y := 0; y < f.h; y++ {
for x := 0; x < f.w; x++ {
on := g[y*f.w+x] != 0 || (bold && x > 0 && g[y*f.w+x-1] != 0)
if !on {
continue
}
for dy := 0; dy < mh; dy++ {
row := (y*mh + dy) * w
for dx := 0; dx < mw; dx++ {
bits[row+x*mw+dx] = 1
}
}
}
}
if p.st.underline > 0 && !p.st.reverse {
for t := 0; t < p.st.underline; t++ {
row := (h - 1 - t) * w
for x := 0; x < w; x++ {
bits[row+x] = 1
}
}
}
if p.st.reverse {
for i := range bits {
bits[i] ^= 1
}
}
return cell{w: w, h: h, bits: bits}
}
func (p *Printer) addChar(r rune) { p.placeCell(p.charCell(r), true) }
func (p *Printer) tab() {
adv := p.currentFont().w + p.st.rightSpacing
for _, t := range p.st.tabs {
if pos := t * adv; pos > p.line.x {
if pos <= p.areaWidth() {
p.line.x = pos
}
return
}
}
}
// textBits dibuja un texto corto con la fuente B (para rótulos del emulador).
func (p *Printer) textBits(s string) ([]byte, int, int) {
f := p.fonts[1]
runes := []rune(s)
w, h := len(runes)*f.w, f.h
bits := make([]byte, w*h)
for i, r := range runes {
g := f.glyph(r)
for y := 0; y < f.h; y++ {
copy(bits[y*w+i*f.w:y*w+(i+1)*f.w], g[y*f.w:(y+1)*f.w])
}
}
return bits, w, h
}
// ---------------------------------------------------------------------------
// Imágenes
// addBitImage procesa ESC * (imagen por columnas, se imprime con la línea).
func (p *Printer) addBitImage(m byte, k int, data []byte) {
hs, vs, bpc := 1, 1, 3
switch m {
case 0:
hs, vs, bpc = 2, 3, 1
case 1:
hs, vs, bpc = 1, 3, 1
case 32:
hs, vs, bpc = 2, 1, 3
}
w, h := k*hs, 8*bpc*vs
bits := make([]byte, w*h)
for col := 0; col < k; col++ {
for bi := 0; bi < bpc; bi++ {
v := data[col*bpc+bi]
for bit := 0; bit < 8; bit++ {
if v&(0x80>>bit) == 0 {
continue
}
y0, x0 := (bi*8+bit)*vs, col*hs
for dy := 0; dy < vs; dy++ {
for dx := 0; dx < hs; dx++ {
bits[(y0+dy)*w+x0+dx] = 1
}
}
}
}
}
p.placeCell(cell{w: w, h: h, bits: bits}, false)
}
// printRaster procesa GS v 0 (imagen por filas, 1 bit por punto, MSB a la izquierda).
func (p *Printer) printRaster(m byte, xBytes, rows int, data []byte) {
sx, sy := 1, 1
if m&1 != 0 {
sx = 2
}
if m&2 != 0 {
sy = 2
}
w, h := xBytes*8*sx, rows*sy
bits := make([]byte, w*h)
for y := 0; y < rows; y++ {
for xb := 0; xb < xBytes; xb++ {
v := data[y*xBytes+xb]
for bit := 0; bit < 8; bit++ {
if v&(0x80>>bit) == 0 {
continue
}
x0 := (xb*8 + bit) * sx
for dy := 0; dy < sy; dy++ {
for dx := 0; dx < sx; dx++ {
bits[(y*sy+dy)*w+x0+dx] = 1
}
}
}
}
}
p.flushLine()
p.printBlock(bits, w, h)
}
func (p *Printer) printQR(s *Session) {
if len(p.st.qrData) == 0 {
p.warn(s, "GS ( k 81: no hay datos QR almacenados (falta la función 80)")
return
}
level := map[byte]qr.Level{48: qr.L, 49: qr.M, 50: qr.Q, 51: qr.H}[p.st.qrECC]
code, err := qr.Encode(string(p.st.qrData), level)
if err != nil {
p.warn(s, "QR no generado: %v", err)
return
}
ms := int(p.st.qrModule)
w := code.Size * ms
bits := make([]byte, w*w)
for y := 0; y < code.Size; y++ {
for x := 0; x < code.Size; x++ {
if !code.Black(x, y) {
continue
}
for dy := 0; dy < ms; dy++ {
for dx := 0; dx < ms; dx++ {
bits[(y*ms+dy)*w+x*ms+dx] = 1
}
}
}
}
p.flushLine()
p.printBlock(bits, w, w)
}
var barcodeNames = map[byte]string{
0: "UPC-A", 1: "UPC-E", 2: "EAN13", 3: "EAN8", 4: "CODE39", 5: "ITF", 6: "CODABAR",
65: "UPC-A", 66: "UPC-E", 67: "EAN13", 68: "EAN8", 69: "CODE39", 70: "ITF", 71: "CODABAR",
72: "CODE93", 73: "CODE128",
}
// printBarcode dibuja un marcador: los códigos de barras no se emulan.
func (p *Printer) printBarcode(m byte, data []byte) {
name := barcodeNames[m]
if name == "" {
name = fmt.Sprintf("tipo %d", m)
}
label, lw, lh := p.textBits(fmt.Sprintf("[%s] %s", name, strings.ToValidUTF8(string(data), "?")))
w := min(max(lw+16, 200), p.areaWidth())
h := max(p.st.barcodeHeight, lh+8)
bits := make([]byte, w*h)
for x := 0; x < w; x++ {
bits[x], bits[(h-1)*w+x] = 1, 1
}
for y := 0; y < h; y++ {
bits[y*w], bits[y*w+w-1] = 1, 1
}
oy, ox := (h-lh)/2, 8
for y := 0; y < lh; y++ {
for x := 0; x < lw && ox+x < w-1; x++ {
bits[(oy+y)*w+ox+x] = label[y*lw+x]
}
}
p.flushLine()
p.printBlock(bits, w, h)
}
+477
View File
@@ -0,0 +1,477 @@
// Package gui muestra la impresora emulada: el papel que sale, los
// interruptores de estado físico y el registro de bytes recibidos.
package gui
import (
"fmt"
"image"
"image/color"
"os"
"gioui.org/app"
"gioui.org/font"
"gioui.org/font/gofont"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"escpos-emulator/internal/escpos"
)
type (
C = layout.Context
D = layout.Dimensions
)
// Info describe por dónde se expone la impresora.
type Info struct {
LAN string
USBIP string
BusID string
}
var (
colSide = rgb(0xf3f4f6)
colDesk = rgb(0x3a3f47)
colLogBg = rgb(0xffffff)
colMuted = rgb(0x6b7280)
colDanger = rgb(0xc62828)
colOK = rgb(0x2e7d32)
colText = rgb(0x1b5e20)
colReply = rgb(0x1565c0)
colWarn = rgb(0xb45309)
colEvent = rgb(0x6b7280)
colCmd = rgb(0x111827)
colCutMark = rgb(0xaab0b8)
colHead = rgb(0x22262b)
)
var zoomLevels = []float32{0.5, 0.75, 1, 1.25, 1.5, 2}
type ui struct {
p *escpos.Printer
info Info
w *app.Window
th *material.Theme
coverOpen, paperEnd, nearEnd, cutterErr, offline widget.Bool
closeDrawer, tear, powerCycle, clearTickets, savePNG widget.Clickable
saveCapture, clearCapture, zoomIn, zoomOut widget.Clickable
side, paperList, logList widget.List
ticketOps map[*escpos.Ticket]paint.ImageOp
curImg *image.Gray
curOp paint.ImageOp
zoom int
message string
}
// Run abre la ventana y no retorna: al cerrarla termina el proceso.
func Run(p *escpos.Printer, info Info) {
th := material.NewTheme()
th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
th.TextSize = 14
u := &ui{p: p, info: info, th: th, ticketOps: map[*escpos.Ticket]paint.ImageOp{}, zoom: 2}
u.side.Axis = layout.Vertical
u.paperList.Axis = layout.Vertical
u.paperList.ScrollToEnd = true
u.logList.Axis = layout.Vertical
u.logList.ScrollToEnd = true
u.w = new(app.Window)
u.w.Option(app.Title("Emulador ESC/POS — "+p.Profile().Name), app.Size(unit.Dp(1400), unit.Dp(900)))
p.SetOnChange(u.w.Invalidate)
go func() {
var ops op.Ops
for {
switch e := u.w.Event().(type) {
case app.DestroyEvent:
if e.Err != nil {
fmt.Fprintln(os.Stderr, e.Err)
os.Exit(1)
}
os.Exit(0)
case app.FrameEvent:
gtx := app.NewContext(&ops, e)
u.layout(gtx)
e.Frame(gtx.Ops)
}
}
}()
app.Main()
}
func (u *ui) handleEvents(gtx C) {
toggle := func(b *widget.Bool, set func(*escpos.Status, bool)) {
if b.Update(gtx) {
v := b.Value
u.p.UpdateStatus(func(s *escpos.Status) { set(s, v) })
}
}
toggle(&u.coverOpen, func(s *escpos.Status, v bool) { s.CoverOpen = v })
toggle(&u.paperEnd, func(s *escpos.Status, v bool) { s.PaperEnd = v })
toggle(&u.nearEnd, func(s *escpos.Status, v bool) { s.PaperNearEnd = v })
toggle(&u.cutterErr, func(s *escpos.Status, v bool) { s.CutterError = v })
toggle(&u.offline, func(s *escpos.Status, v bool) { s.Offline = v })
if u.closeDrawer.Clicked(gtx) {
u.p.UpdateStatus(func(s *escpos.Status) { s.DrawerOpen = false })
}
if u.tear.Clicked(gtx) {
u.p.TearPaper()
}
if u.powerCycle.Clicked(gtx) {
u.p.PowerCycle()
}
if u.clearTickets.Clicked(gtx) {
u.p.ClearTickets()
u.ticketOps = map[*escpos.Ticket]paint.ImageOp{}
}
if u.savePNG.Clicked(gtx) {
tix := u.p.Tickets()
var last string
for _, t := range tix {
path, err := escpos.SaveTicketPNG("tickets", t)
if err != nil {
u.message = "Error: " + err.Error()
return
}
last = path
}
if len(tix) == 0 {
u.message = "No hay tickets cortados que guardar"
} else {
u.message = fmt.Sprintf("%d tickets guardados (último: %s)", len(tix), last)
}
}
if u.saveCapture.Clicked(gtx) {
path, n, err := u.p.SaveCapture("capturas")
if err != nil {
u.message = "Error: " + err.Error()
} else {
u.message = fmt.Sprintf("%d bytes guardados en %s", n, path)
}
}
if u.clearCapture.Clicked(gtx) {
u.p.ClearCapture()
u.message = "Captura vaciada"
}
if u.zoomIn.Clicked(gtx) && u.zoom < len(zoomLevels)-1 {
u.zoom++
}
if u.zoomOut.Clicked(gtx) && u.zoom > 0 {
u.zoom--
}
}
func (u *ui) layout(gtx C) D {
u.handleEvents(gtx)
snap := u.p.Snapshot(600)
u.coverOpen.Value = snap.Status.CoverOpen
u.paperEnd.Value = snap.Status.PaperEnd
u.nearEnd.Value = snap.Status.PaperNearEnd
u.cutterErr.Value = snap.Status.CutterError
u.offline.Value = snap.Status.Offline
if len(u.ticketOps) > len(snap.Tickets) {
u.ticketOps = map[*escpos.Ticket]paint.ImageOp{}
}
return layout.Flex{}.Layout(gtx,
layout.Rigid(func(gtx C) D {
gtx.Constraints.Min.X = gtx.Dp(320)
gtx.Constraints.Max.X = gtx.Constraints.Min.X
return pane(gtx, colSide, func(gtx C) D { return u.sidebar(gtx, snap) })
}),
layout.Flexed(0.55, func(gtx C) D {
return pane(gtx, colDesk, func(gtx C) D { return u.paperView(gtx, snap) })
}),
layout.Flexed(0.45, func(gtx C) D {
return pane(gtx, colLogBg, func(gtx C) D { return u.logView(gtx, snap) })
}),
)
}
// ---------------------------------------------------------------------------
// Panel lateral
func (u *ui) sidebar(gtx C, snap escpos.Snapshot) D {
prof := snap.Profile
st := snap.Status
var items []layout.Widget
add := func(w ...layout.Widget) { items = append(items, w...) }
add(u.label(unit.Sp(20), "Emulador ESC/POS", colCmd, font.Bold))
add(u.label(unit.Sp(13), prof.Name, colMuted, font.Normal))
cutter := "sin cuchilla"
if prof.HasCutter {
cutter = fmt.Sprintf("cuchilla a %d mm del cabezal", prof.CutterOffset/8)
}
add(u.label(unit.Sp(13), fmt.Sprintf("Papel %d mm · %d puntos imprimibles · %s", prof.PaperDots/8, prof.WidthDots, cutter), colMuted, font.Normal))
add(u.section("Conexiones"))
if u.info.LAN != "" {
add(u.label(unit.Sp(13), "LAN (TCP RAW): "+u.info.LAN, colCmd, font.Normal))
}
if u.info.USBIP != "" {
add(u.label(unit.Sp(13), fmt.Sprintf("USB/IP: %s · busid %s · %04X:%04X", u.info.USBIP, u.info.BusID, prof.USBVendorID, prof.USBProductID), colCmd, font.Normal))
}
if len(snap.Connections) == 0 {
add(u.label(unit.Sp(13), "Ninguna conexión activa", colMuted, font.Normal))
}
for _, c := range snap.Connections {
add(u.label(unit.Sp(13), "● "+c, colOK, font.Normal))
}
add(u.section("Estado físico"))
add(u.check(&u.coverOpen, "Tapa abierta"))
add(u.check(&u.paperEnd, "Sin papel"))
add(u.check(&u.nearEnd, "Papel casi agotado"))
add(u.check(&u.cutterErr, "Error de cuchilla"))
add(u.check(&u.offline, "Offline (pausa manual)"))
if st.DrawerOpen {
add(u.label(unit.Sp(14), "Cajón: ABIERTO", colDanger, font.Bold))
add(u.buttons(btn{&u.closeDrawer, "Cerrar cajón"}))
} else {
add(u.label(unit.Sp(14), "Cajón: cerrado", colMuted, font.Normal))
}
if st.Blocked() {
add(u.label(unit.Sp(14), fmt.Sprintf("IMPRESORA DETENIDA · %d bytes en el buffer", snap.PendingBytes), colDanger, font.Bold))
} else if snap.PendingBytes > 0 {
add(u.label(unit.Sp(13), fmt.Sprintf("Esperando el resto de un comando: %d bytes", snap.PendingBytes), colWarn, font.Normal))
}
add(u.section("Modo de impresión actual"))
add(u.label(unit.Sp(13), snap.Mode, colCmd, font.Normal))
add(u.section("Papel"))
add(u.buttons(btn{&u.tear, "Arrancar papel"}, btn{&u.clearTickets, "Borrar tickets"}))
add(u.buttons(btn{&u.savePNG, "Guardar PNG"}, btn{&u.zoomOut, "−"}, btn{&u.zoomIn, "+"}))
add(u.label(unit.Sp(12), fmt.Sprintf("Zoom %.0f%% · %d tickets cortados", zoomLevels[u.zoom]*100, len(snap.Tickets)), colMuted, font.Normal))
add(u.section("Captura de bytes"))
add(u.label(unit.Sp(13), fmt.Sprintf("%d bytes recibidos desde el último vaciado", snap.CaptureBytes), colCmd, font.Normal))
add(u.buttons(btn{&u.saveCapture, "Guardar .bin"}, btn{&u.clearCapture, "Vaciar"}))
add(u.section("Alimentación"))
add(u.buttons(btn{&u.powerCycle, "Apagar y encender"}))
if u.message != "" {
add(u.section("Última acción"))
add(u.label(unit.Sp(12), u.message, colMuted, font.Normal))
}
return material.List(u.th, &u.side).Layout(gtx, len(items), func(gtx C, i int) D {
return layout.Inset{Left: unit.Dp(14), Right: unit.Dp(14), Top: unit.Dp(3), Bottom: unit.Dp(3)}.Layout(gtx, items[i])
})
}
func (u *ui) label(size unit.Sp, txt string, c color.NRGBA, weight font.Weight) layout.Widget {
return func(gtx C) D {
l := material.Label(u.th, size, txt)
l.Color = c
l.Font.Weight = weight
return l.Layout(gtx)
}
}
func (u *ui) section(title string) layout.Widget {
return func(gtx C) D {
return layout.Inset{Top: unit.Dp(12)}.Layout(gtx, u.label(unit.Sp(12), title, colMuted, font.Bold))
}
}
func (u *ui) check(b *widget.Bool, txt string) layout.Widget {
return func(gtx C) D {
cb := material.CheckBox(u.th, b, txt)
cb.TextSize = unit.Sp(14)
cb.Size = unit.Dp(20)
if b.Value {
cb.Color = colDanger
cb.IconColor = colDanger
}
return cb.Layout(gtx)
}
}
type btn struct {
c *widget.Clickable
label string
}
func (u *ui) buttons(bs ...btn) layout.Widget {
return func(gtx C) D {
children := make([]layout.FlexChild, 0, len(bs))
for _, b := range bs {
children = append(children, layout.Rigid(func(gtx C) D {
return layout.Inset{Right: unit.Dp(6), Top: unit.Dp(2)}.Layout(gtx, func(gtx C) D {
bt := material.Button(u.th, b.c, b.label)
bt.TextSize = unit.Sp(13)
bt.Inset = layout.Inset{Top: unit.Dp(6), Bottom: unit.Dp(6), Left: unit.Dp(10), Right: unit.Dp(10)}
return bt.Layout(gtx)
})
}))
}
return layout.Flex{}.Layout(gtx, children...)
}
}
// ---------------------------------------------------------------------------
// Papel
func (u *ui) scale(gtx C) float32 { return zoomLevels[u.zoom] / gtx.Metric.PxPerDp }
func (u *ui) paperView(gtx C, snap escpos.Snapshot) D {
n := len(snap.Tickets) + 1
return material.List(u.th, &u.paperList).Layout(gtx, n, func(gtx C, i int) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
if i < len(snap.Tickets) {
return u.ticketItem(gtx, snap.Tickets[i])
}
return u.currentPaper(gtx, snap)
})
}
func (u *ui) ticketItem(gtx C, t *escpos.Ticket) D {
imgOp, ok := u.ticketOps[t]
if !ok {
imgOp = paint.NewImageOp(t.Image)
imgOp.Filter = paint.FilterNearest
u.ticketOps[t] = imgOp
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Inset{Top: unit.Dp(16)}.Layout(gtx, func(gtx C) D {
return center(gtx, widget.Image{Src: imgOp, Scale: u.scale(gtx)}.Layout)
})
}),
layout.Rigid(func(gtx C) D {
txt := fmt.Sprintf("✂ ─ ─ ─ corte %s · ticket #%d · %s ─ ─ ─", t.Cut, t.Seq, t.Time.Format("15:04:05"))
return center(gtx, u.label(unit.Sp(12), txt, colCutMark, font.Normal))
}),
)
}
func (u *ui) currentPaper(gtx C, snap escpos.Snapshot) D {
paperW := gtx.Dp(unit.Dp(float32(snap.Profile.PaperDots) * u.scale(gtx)))
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Inset{Top: unit.Dp(24)}.Layout(gtx, func(gtx C) D {
return center(gtx, u.label(unit.Sp(12), "Papel dentro de la impresora (aún sin cortar)", colCutMark, font.Normal))
})
}),
layout.Rigid(func(gtx C) D {
if snap.Paper == nil {
return D{}
}
if snap.Paper != u.curImg {
u.curImg = snap.Paper
u.curOp = paint.NewImageOp(snap.Paper)
u.curOp.Filter = paint.FilterNearest
}
return center(gtx, func(gtx C) D {
dims := widget.Image{Src: u.curOp, Scale: u.scale(gtx)}.Layout(gtx)
if snap.CutterRow > 0 {
y := snap.CutterRow * dims.Size.Y / snap.Paper.Bounds().Dy()
st := op.Offset(image.Pt(0, y)).Push(gtx.Ops)
paint.FillShape(gtx.Ops, colDanger, clip.Rect{Max: image.Pt(dims.Size.X, max(1, gtx.Dp(1)))}.Op())
st.Pop()
}
return dims
})
}),
layout.Rigid(func(gtx C) D {
return center(gtx, func(gtx C) D {
sz := image.Pt(paperW+gtx.Dp(40), gtx.Dp(34))
paint.FillShape(gtx.Ops, colHead, clip.UniformRRect(image.Rectangle{Max: sz}, gtx.Dp(4)).Op(gtx.Ops))
st := op.Offset(image.Pt(gtx.Dp(12), gtx.Dp(8))).Push(gtx.Ops)
msg := "▲ cabezal térmico"
if snap.Profile.HasCutter {
msg += " · la línea roja marca la cuchilla"
}
u.label(unit.Sp(12), msg, rgb(0xd1d5db), font.Normal)(gtx)
st.Pop()
return D{Size: sz}
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(24)}.Layout),
)
}
// ---------------------------------------------------------------------------
// Registro
func kindColor(k escpos.LogKind) color.NRGBA {
switch k {
case escpos.LogText:
return colText
case escpos.LogResponse:
return colReply
case escpos.LogWarning:
return colWarn
case escpos.LogEvent:
return colEvent
}
return colCmd
}
func (u *ui) logView(gtx C, snap escpos.Snapshot) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Inset{Left: unit.Dp(10), Top: unit.Dp(10), Bottom: unit.Dp(6)}.Layout(gtx,
u.label(unit.Sp(14), "Registro: bytes recibidos y su significado", colCmd, font.Bold))
}),
layout.Flexed(1, func(gtx C) D {
return material.List(u.th, &u.logList).Layout(gtx, len(snap.Log), func(gtx C, i int) D {
e := snap.Log[i]
return layout.Inset{Left: unit.Dp(10), Right: unit.Dp(10), Top: unit.Dp(3), Bottom: unit.Dp(3)}.Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
head := fmt.Sprintf("%s [%s] %s", e.Time.Format("15:04:05.000"), e.Source, e.Text)
return u.label(unit.Sp(13), head, kindColor(e.Kind), font.Normal)(gtx)
}),
layout.Rigid(func(gtx C) D {
if e.Hex == "" {
return D{}
}
l := material.Label(u.th, unit.Sp(12), e.Hex)
l.Font.Typeface = "Go Mono"
l.Color = colMuted
return l.Layout(gtx)
}),
)
})
})
}),
)
}
// ---------------------------------------------------------------------------
func pane(gtx C, c color.NRGBA, w layout.Widget) D {
size := gtx.Constraints.Max
paint.FillShape(gtx.Ops, c, clip.Rect{Max: size}.Op())
gtx.Constraints.Min = size
w(gtx)
return D{Size: size}
}
func rgb(v uint32) color.NRGBA {
return color.NRGBA{R: uint8(v >> 16), G: uint8(v >> 8), B: uint8(v), A: 0xff}
}
// center centra w horizontalmente en todo el ancho disponible.
func center(gtx C, w layout.Widget) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return layout.Center.Layout(gtx, w)
}
+65
View File
@@ -0,0 +1,65 @@
// Package lan expone la impresora por TCP en modo RAW (puerto 9100,
// también llamado JetDirect / AppSocket), como las impresoras de red reales.
package lan
import (
"errors"
"net"
"escpos-emulator/internal/escpos"
)
// Server acepta conexiones TCP y entrega los bytes a la impresora.
type Server struct {
ln net.Listener
printer *escpos.Printer
}
// Listen abre el puerto y empieza a aceptar conexiones en segundo plano.
func Listen(addr string, p *escpos.Printer) (*Server, error) {
ln, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
s := &Server{ln: ln, printer: p}
go s.serve()
return s, nil
}
// Addr devuelve la dirección en la que escucha.
func (s *Server) Addr() net.Addr { return s.ln.Addr() }
// Close deja de aceptar conexiones.
func (s *Server) Close() error { return s.ln.Close() }
func (s *Server) serve() {
for {
conn, err := s.ln.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return
}
continue
}
go s.handle(conn)
}
}
func (s *Server) handle(conn net.Conn) {
defer conn.Close()
session := s.printer.NewSession("LAN "+conn.RemoteAddr().String(), func(b []byte) {
_, _ = conn.Write(b)
})
defer session.Close()
buf := make([]byte, 4096)
for {
n, err := conn.Read(buf)
if n > 0 {
session.Write(buf[:n])
}
if err != nil {
return
}
}
}
+485
View File
@@ -0,0 +1,485 @@
// Package usbip exporta la impresora como un dispositivo USB real mediante
// el protocolo USB/IP. Un cliente USB/IP (usbip-win2 en Windows, usbip en
// Linux) conecta el dispositivo al sistema operativo, que lo ve como una
// impresora USB más: clase 07 (impresora), subclase 01, protocolo 02
// (bidireccional), con un endpoint bulk OUT (EP 1) y uno bulk IN (EP 2).
//
// Protocolo: https://docs.kernel.org/usb/usbip_protocol.html
package usbip
import (
"bytes"
"encoding/binary"
"errors"
"io"
"net"
"sync"
"unicode/utf16"
"escpos-emulator/internal/escpos"
)
const (
protocolVersion = 0x0111
opReqDevlist = 0x8005
opRepDevlist = 0x0005
opReqImport = 0x8003
opRepImport = 0x0003
cmdSubmit = 1
cmdUnlink = 2
retSubmit = 3
retUnlink = 4
dirOut = 0
dirIn = 1
epBulkOut = 1
epBulkIn = 2
speedFull = 2
errPipe = -32 // -EPIPE: STALL
errConnReset = -104 // -ECONNRESET: URB cancelada
maxInBuffer = 4096
maxTransfer = 16 << 20
)
// BusID es el identificador con el que se conecta el dispositivo
// (usbip attach -r <host> -b 1-1).
const BusID = "1-1"
// Device describe la identidad USB del dispositivo.
type Device struct {
VendorID uint16
ProductID uint16
Manufacturer string
Product string
Serial string
IEEE1284ID string // respuesta a GET_DEVICE_ID de la clase impresora
}
// DeviceFromProfile construye la identidad USB a partir del perfil.
func DeviceFromProfile(p escpos.Profile) Device {
return Device{
VendorID: p.USBVendorID,
ProductID: p.USBProductID,
Manufacturer: p.Maker,
Product: p.Model,
Serial: p.Serial,
IEEE1284ID: "MFG:" + p.Maker + ";MDL:" + p.Model + ";CMD:ESC/POS;CLS:PRINTER;DES:" + p.Name + ";",
}
}
// Server atiende clientes USB/IP.
type Server struct {
ln net.Listener
dev Device
printer *escpos.Printer
mu sync.Mutex
attached bool
}
// Listen abre el puerto (normalmente 3240) y atiende en segundo plano.
func Listen(addr string, dev Device, p *escpos.Printer) (*Server, error) {
ln, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
s := &Server{ln: ln, dev: dev, printer: p}
go s.serve()
return s, nil
}
// Addr devuelve la dirección en la que escucha.
func (s *Server) Addr() net.Addr { return s.ln.Addr() }
// Close deja de aceptar conexiones.
func (s *Server) Close() error { return s.ln.Close() }
func (s *Server) serve() {
for {
conn, err := s.ln.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return
}
continue
}
go s.handle(conn)
}
}
func (s *Server) handle(conn net.Conn) {
defer conn.Close()
for {
var h [8]byte
if _, err := io.ReadFull(conn, h[:]); err != nil {
return
}
switch binary.BigEndian.Uint16(h[2:]) {
case opReqDevlist:
reply := opHeader(opRepDevlist, 0)
reply = binary.BigEndian.AppendUint32(reply, 1)
reply = append(reply, s.deviceRecord()...)
reply = append(reply, 0x07, 0x01, 0x02, 0x00) // interfaz 0: clase, subclase, protocolo, relleno
if _, err := conn.Write(reply); err != nil {
return
}
case opReqImport:
var bus [32]byte
if _, err := io.ReadFull(conn, bus[:]); err != nil {
return
}
id := string(bytes.TrimRight(bus[:], "\x00"))
if id != BusID || !s.acquire() {
s.printer.Note("USB/IP: rechazada la conexión a %q desde %s (no existe o ya está conectado)", id, conn.RemoteAddr())
_, _ = conn.Write(opHeader(opRepImport, 1))
return
}
_, err := conn.Write(append(opHeader(opRepImport, 0), s.deviceRecord()...))
if err == nil {
s.runDevice(conn)
}
s.release()
return
default:
return
}
}
}
func (s *Server) acquire() bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.attached {
return false
}
s.attached = true
return true
}
func (s *Server) release() {
s.mu.Lock()
s.attached = false
s.mu.Unlock()
}
func opHeader(code uint16, status uint32) []byte {
b := binary.BigEndian.AppendUint16(nil, protocolVersion)
b = binary.BigEndian.AppendUint16(b, code)
return binary.BigEndian.AppendUint32(b, status)
}
// deviceRecord es la estructura usbip_usb_device (312 bytes, big endian).
func (s *Server) deviceRecord() []byte {
b := make([]byte, 312)
copy(b[0:256], "/sys/devices/platform/escpos-emulator/usb1/"+BusID)
copy(b[256:288], BusID)
binary.BigEndian.PutUint32(b[288:], 1) // busnum
binary.BigEndian.PutUint32(b[292:], 2) // devnum
binary.BigEndian.PutUint32(b[296:], speedFull)
binary.BigEndian.PutUint16(b[300:], s.dev.VendorID)
binary.BigEndian.PutUint16(b[302:], s.dev.ProductID)
binary.BigEndian.PutUint16(b[304:], 0x0100) // bcdDevice
b[306], b[307], b[308] = 0, 0, 0 // la clase se declara en la interfaz
b[309] = 1 // bConfigurationValue
b[310] = 1 // bNumConfigurations
b[311] = 1 // bNumInterfaces
return b
}
// ---------------------------------------------------------------------------
// Dispositivo conectado: intercambio de URBs.
type inURB struct {
seq uint32
length int
numPackets uint32
}
type devConn struct {
srv *Server
conn net.Conn
session *escpos.Session
wmu sync.Mutex // serializa las escrituras en conn
mu sync.Mutex
pendingIn []inURB
inBuf []byte
config byte
}
func (s *Server) runDevice(conn net.Conn) {
d := &devConn{srv: s, conn: conn}
d.session = s.printer.NewSession("USB "+conn.RemoteAddr().String(), d.pushIn)
defer d.session.Close()
var h [48]byte
for {
if _, err := io.ReadFull(conn, h[:]); err != nil {
return
}
var err error
switch binary.BigEndian.Uint32(h[0:]) {
case cmdSubmit:
err = d.submit(h)
case cmdUnlink:
err = d.unlink(h)
default:
return
}
if err != nil {
return
}
}
}
func (d *devConn) submit(h [48]byte) error {
seq := binary.BigEndian.Uint32(h[4:])
dir := binary.BigEndian.Uint32(h[12:])
ep := binary.BigEndian.Uint32(h[16:])
length := binary.BigEndian.Uint32(h[24:])
numPackets := binary.BigEndian.Uint32(h[32:])
var setup [8]byte
copy(setup[:], h[40:48])
if length > maxTransfer {
return errors.New("usbip: transferencia demasiado grande")
}
var data []byte
if dir == dirOut && length > 0 {
data = make([]byte, length)
if _, err := io.ReadFull(d.conn, data); err != nil {
return err
}
}
switch {
case ep == 0:
return d.control(seq, numPackets, setup, data)
case ep == epBulkOut && dir == dirOut:
d.session.Write(data)
return d.reply(seq, numPackets, 0, len(data), nil)
case ep == epBulkIn && dir == dirIn:
// El host deja una lectura pendiente; se completa cuando la
// impresora tenga algo que enviar (mientras, el USB responde NAK).
d.mu.Lock()
d.pendingIn = append(d.pendingIn, inURB{seq: seq, length: int(length), numPackets: numPackets})
d.mu.Unlock()
return d.drainIn()
default:
return d.reply(seq, numPackets, errPipe, 0, nil)
}
}
func (d *devConn) unlink(h [48]byte) error {
seq := binary.BigEndian.Uint32(h[4:])
target := binary.BigEndian.Uint32(h[20:])
d.wmu.Lock()
defer d.wmu.Unlock()
d.mu.Lock()
var status int32
for i, u := range d.pendingIn {
if u.seq == target {
d.pendingIn = append(d.pendingIn[:i], d.pendingIn[i+1:]...)
status = errConnReset
break
}
}
d.mu.Unlock()
b := make([]byte, 48)
binary.BigEndian.PutUint32(b[0:], retUnlink)
binary.BigEndian.PutUint32(b[4:], seq)
binary.BigEndian.PutUint32(b[20:], uint32(status))
_, err := d.conn.Write(b)
return err
}
// pushIn recibe las respuestas de la impresora (DLE EOT, GS I...).
func (d *devConn) pushIn(b []byte) {
d.mu.Lock()
d.inBuf = append(d.inBuf, b...)
if over := len(d.inBuf) - maxInBuffer; over > 0 {
d.inBuf = d.inBuf[over:]
}
d.mu.Unlock()
_ = d.drainIn()
}
// drainIn completa lecturas bulk IN pendientes con los datos disponibles.
func (d *devConn) drainIn() error {
d.wmu.Lock()
defer d.wmu.Unlock()
for {
d.mu.Lock()
if len(d.pendingIn) == 0 || len(d.inBuf) == 0 {
d.mu.Unlock()
return nil
}
u := d.pendingIn[0]
d.pendingIn = d.pendingIn[1:]
n := min(u.length, len(d.inBuf))
chunk := append([]byte(nil), d.inBuf[:n]...)
d.inBuf = d.inBuf[n:]
d.mu.Unlock()
if _, err := d.conn.Write(retSubmitPacket(u.seq, u.numPackets, 0, n, chunk)); err != nil {
return err
}
}
}
func (d *devConn) reply(seq, numPackets uint32, status int32, actual int, data []byte) error {
d.wmu.Lock()
defer d.wmu.Unlock()
_, err := d.conn.Write(retSubmitPacket(seq, numPackets, status, actual, data))
return err
}
func retSubmitPacket(seq, numPackets uint32, status int32, actual int, data []byte) []byte {
b := make([]byte, 48, 48+len(data))
binary.BigEndian.PutUint32(b[0:], retSubmit)
binary.BigEndian.PutUint32(b[4:], seq)
binary.BigEndian.PutUint32(b[20:], uint32(status))
binary.BigEndian.PutUint32(b[24:], uint32(actual))
// Igual que el servidor de Linux: se devuelve el mismo number_of_packets
// que envió el cliente (0 o 0xFFFFFFFF en transferencias no isócronas).
binary.BigEndian.PutUint32(b[32:], numPackets)
return append(b, data...)
}
// ---------------------------------------------------------------------------
// Endpoint 0: peticiones de control.
func (d *devConn) control(seq, numPackets uint32, setup [8]byte, data []byte) error {
reqType, req := setup[0], setup[1]
value := binary.LittleEndian.Uint16(setup[2:])
wLength := int(binary.LittleEndian.Uint16(setup[6:]))
var resp []byte
ok := false
switch reqType & 0x60 {
case 0x00:
resp, ok = d.standardRequest(reqType, req, value)
case 0x20:
resp, ok = d.classRequest(reqType, req)
}
if !ok {
return d.reply(seq, numPackets, errPipe, 0, nil)
}
if reqType&0x80 == 0 {
return d.reply(seq, numPackets, 0, len(data), nil)
}
if len(resp) > wLength {
resp = resp[:wLength]
}
return d.reply(seq, numPackets, 0, len(resp), resp)
}
func (d *devConn) standardRequest(reqType, req byte, value uint16) ([]byte, bool) {
switch req {
case 0x00: // GET_STATUS
if reqType&0x1F == 0 {
return []byte{0x01, 0x00}, true // autoalimentado
}
return []byte{0x00, 0x00}, true
case 0x01, 0x03, 0x05: // CLEAR_FEATURE, SET_FEATURE, SET_ADDRESS
return nil, true
case 0x06: // GET_DESCRIPTOR
return d.srv.dev.descriptor(byte(value>>8), byte(value))
case 0x08: // GET_CONFIGURATION
d.mu.Lock()
defer d.mu.Unlock()
return []byte{d.config}, true
case 0x09: // SET_CONFIGURATION
d.mu.Lock()
d.config = byte(value)
d.mu.Unlock()
return nil, true
case 0x0A: // GET_INTERFACE
return []byte{0x00}, true
case 0x0B: // SET_INTERFACE
return nil, value == 0
}
return nil, false
}
// classRequest atiende las peticiones de la clase impresora USB 1.1.
func (d *devConn) classRequest(reqType, req byte) ([]byte, bool) {
switch {
case reqType == 0xA1 && req == 0: // GET_DEVICE_ID
id := d.srv.dev.IEEE1284ID
n := len(id) + 2
return append([]byte{byte(n >> 8), byte(n)}, id...), true
case reqType == 0xA1 && req == 1: // GET_PORT_STATUS
st := d.srv.printer.Status()
v := byte(0x18) // bit 3: sin error, bit 4: seleccionada
if st.Blocked() {
v &^= 0x08
}
if st.Offline {
v &^= 0x10
}
if st.PaperEnd {
v |= 0x20
}
return []byte{v}, true
case reqType == 0x21 && req == 2: // SOFT_RESET
return nil, true
}
return nil, false
}
// descriptor devuelve los descriptores USB estándar (little endian).
func (dev Device) descriptor(typ, index byte) ([]byte, bool) {
switch typ {
case 1: // dispositivo
return []byte{
18, 1, 0x00, 0x02, // bLength, DEVICE, bcdUSB 2.00
0, 0, 0, 64, // clase/subclase/protocolo en la interfaz, bMaxPacketSize0
byte(dev.VendorID), byte(dev.VendorID >> 8),
byte(dev.ProductID), byte(dev.ProductID >> 8),
0x00, 0x01, // bcdDevice 1.00
1, 2, 3, // iManufacturer, iProduct, iSerialNumber
1, // bNumConfigurations
}, true
case 2: // configuración + interfaz + endpoints
if index != 0 {
return nil, false
}
return []byte{
9, 2, 32, 0, 1, 1, 0, 0xC0, 1, // CONFIGURATION: wTotalLength 32, 1 interfaz, autoalimentado
9, 4, 0, 0, 2, 0x07, 0x01, 0x02, 0, // INTERFACE: 2 endpoints, impresora bidireccional
7, 5, 0x01, 0x02, 64, 0, 0, // ENDPOINT 1 OUT, bulk, 64 bytes
7, 5, 0x82, 0x02, 64, 0, 0, // ENDPOINT 2 IN, bulk, 64 bytes
}, true
case 3: // cadenas
switch index {
case 0:
return []byte{4, 3, 0x09, 0x04}, true // idioma: inglés EE.UU.
case 1:
return stringDescriptor(dev.Manufacturer), true
case 2:
return stringDescriptor(dev.Product), true
case 3:
return stringDescriptor(dev.Serial), true
}
}
// DEVICE_QUALIFIER, BOS...: un dispositivo full speed USB 2.00 los rechaza con STALL.
return nil, false
}
func stringDescriptor(s string) []byte {
u := utf16.Encode([]rune(s))
b := []byte{byte(2 + 2*len(u)), 3}
for _, c := range u {
b = append(b, byte(c), byte(c>>8))
}
return b
}
+185
View File
@@ -0,0 +1,185 @@
package usbip
import (
"bytes"
"encoding/binary"
"io"
"net"
"testing"
"time"
"escpos-emulator/internal/escpos"
)
func startServer(t *testing.T) (*Server, *escpos.Printer) {
t.Helper()
prof, _ := escpos.FindProfile("80mm")
p, err := escpos.NewPrinter(prof)
if err != nil {
t.Fatal(err)
}
s, err := Listen("127.0.0.1:0", DeviceFromProfile(prof), p)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
return s, p
}
func dial(t *testing.T, s *Server) net.Conn {
t.Helper()
c, err := net.Dial("tcp", s.Addr().String())
if err != nil {
t.Fatal(err)
}
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
t.Cleanup(func() { c.Close() })
return c
}
func readN(t *testing.T, c net.Conn, n int) []byte {
t.Helper()
b := make([]byte, n)
if _, err := io.ReadFull(c, b); err != nil {
t.Fatalf("leyendo %d bytes: %v", n, err)
}
return b
}
func TestDevlist(t *testing.T) {
s, _ := startServer(t)
c := dial(t, s)
c.Write([]byte{0x01, 0x11, 0x80, 0x05, 0, 0, 0, 0})
h := readN(t, c, 12)
if binary.BigEndian.Uint16(h[2:]) != opRepDevlist || binary.BigEndian.Uint32(h[8:]) != 1 {
t.Fatalf("cabecera DEVLIST inesperada: % X", h)
}
dev := readN(t, c, 312+4)
if got := string(bytes.TrimRight(dev[256:288], "\x00")); got != BusID {
t.Fatalf("busid = %q", got)
}
if !bytes.Equal(dev[312:], []byte{7, 1, 2, 0}) {
t.Fatalf("interfaz = % X, want clase impresora 07 01 02", dev[312:])
}
}
type client struct {
t *testing.T
c net.Conn
seq uint32
isIn map[uint32]bool
}
func attach(t *testing.T, s *Server) *client {
c := dial(t, s)
req := []byte{0x01, 0x11, 0x80, 0x03, 0, 0, 0, 0}
bus := make([]byte, 32)
copy(bus, BusID)
c.Write(append(req, bus...))
h := readN(t, c, 8)
if binary.BigEndian.Uint32(h[4:]) != 0 {
t.Fatalf("IMPORT rechazado: % X", h)
}
readN(t, c, 312)
return &client{t: t, c: c, isIn: map[uint32]bool{}}
}
func (cl *client) submit(dir, ep uint32, length int, setup []byte, data []byte) uint32 {
cl.seq++
cl.isIn[cl.seq] = dir == dirIn
h := make([]byte, 48)
binary.BigEndian.PutUint32(h[0:], cmdSubmit)
binary.BigEndian.PutUint32(h[4:], cl.seq)
binary.BigEndian.PutUint32(h[8:], 0x00010002)
binary.BigEndian.PutUint32(h[12:], dir)
binary.BigEndian.PutUint32(h[16:], ep)
binary.BigEndian.PutUint32(h[24:], uint32(length))
binary.BigEndian.PutUint32(h[32:], 0xFFFFFFFF)
copy(h[40:], setup)
cl.c.Write(append(h, data...))
return cl.seq
}
func (cl *client) ret() (seq uint32, status int32, data []byte) {
h := readN(cl.t, cl.c, 48)
seq = binary.BigEndian.Uint32(h[4:])
status = int32(binary.BigEndian.Uint32(h[20:]))
if n := int(binary.BigEndian.Uint32(h[24:])); cl.isIn[seq] && n > 0 {
data = readN(cl.t, cl.c, n)
}
return
}
func TestControlAndBulk(t *testing.T) {
s, p := startServer(t)
cl := attach(t, s)
// GET_DESCRIPTOR(DEVICE)
cl.submit(dirIn, 0, 18, []byte{0x80, 6, 0, 1, 0, 0, 18, 0}, nil)
_, st, d := cl.ret()
if st != 0 || len(d) != 18 || d[1] != 1 {
t.Fatalf("descriptor de dispositivo: status %d, % X", st, d)
}
if vid := binary.LittleEndian.Uint16(d[8:]); vid != 0x1209 {
t.Fatalf("VID = %04X", vid)
}
// GET_DESCRIPTOR(CONFIGURATION) con wLength 9: solo la cabecera
cl.submit(dirIn, 0, 9, []byte{0x80, 6, 0, 2, 0, 0, 9, 0}, nil)
_, _, d = cl.ret()
if len(d) != 9 || d[2] != 32 {
t.Fatalf("configuración: % X", d)
}
// GET_DEVICE_ID de la clase impresora
cl.submit(dirIn, 0, 1024, []byte{0xA1, 0, 0, 0, 0, 0, 0, 4}, nil)
_, _, d = cl.ret()
if !bytes.Contains(d, []byte("CMD:ESC/POS")) {
t.Fatalf("GET_DEVICE_ID = %q", d)
}
// DEVICE_QUALIFIER debe dar STALL
cl.submit(dirIn, 0, 10, []byte{0x80, 6, 0, 6, 0, 0, 10, 0}, nil)
if _, st, _ = cl.ret(); st != errPipe {
t.Fatalf("DEVICE_QUALIFIER status = %d, want %d", st, errPipe)
}
// Lectura bulk IN pendiente antes de que haya datos
inSeq := cl.submit(dirIn, epBulkIn, 64, nil, nil)
// Bulk OUT: texto + DLE EOT 1
payload := []byte("\x1b@Hola USB\n\x10\x04\x01")
outSeq := cl.submit(dirOut, epBulkOut, len(payload), nil, payload)
got := map[uint32][]byte{}
for i := 0; i < 2; i++ {
seq, st, data := cl.ret()
if st != 0 {
t.Fatalf("seq %d status %d", seq, st)
}
got[seq] = data
}
if !bytes.Equal(got[inSeq], []byte{0x12}) {
t.Fatalf("bulk IN = % X, want 12", got[inSeq])
}
if _, ok := got[outSeq]; !ok {
t.Fatal("falta la respuesta del bulk OUT")
}
// UNLINK de una lectura pendiente
pending := cl.submit(dirIn, epBulkIn, 64, nil, nil)
cl.seq++
u := make([]byte, 48)
binary.BigEndian.PutUint32(u[0:], cmdUnlink)
binary.BigEndian.PutUint32(u[4:], cl.seq)
binary.BigEndian.PutUint32(u[20:], pending)
cl.c.Write(u)
h := readN(t, cl.c, 48)
if binary.BigEndian.Uint32(h[0:]) != retUnlink || int32(binary.BigEndian.Uint32(h[20:])) != errConnReset {
t.Fatalf("RET_UNLINK inesperado: % X", h)
}
if len(p.Snapshot(0).Connections) != 1 {
t.Fatal("debería haber una conexión USB activa")
}
}
+85
View File
@@ -0,0 +1,85 @@
// Emulador de impresora térmica ESC/POS con conexión LAN (TCP 9100) y USB
// (a través de USB/IP).
package main
import (
"flag"
"fmt"
"os"
"os/signal"
"escpos-emulator/internal/escpos"
"escpos-emulator/internal/gui"
"escpos-emulator/internal/lan"
"escpos-emulator/internal/usbip"
)
func main() {
lanAddr := flag.String("lan", ":9100", "dirección TCP del puerto RAW; vacío para desactivar")
usbAddr := flag.String("usbip", ":3240", "dirección del servidor USB/IP; vacío para desactivar")
profileKey := flag.String("perfil", "80mm", "modelo emulado: 80mm o 58mm")
noGUI := flag.Bool("nogui", false, "sin ventana: registro por consola y tickets a PNG")
ticketDir := flag.String("tickets", "", "carpeta donde guardar cada ticket cortado como PNG (en -nogui: \"tickets\")")
flag.Parse()
prof, ok := escpos.FindProfile(*profileKey)
if !ok {
fmt.Fprintf(os.Stderr, "perfil desconocido %q. Disponibles:\n", *profileKey)
for _, p := range escpos.Profiles() {
fmt.Fprintf(os.Stderr, " %-6s %s\n", p.Key, p.Name)
}
os.Exit(2)
}
p, err := escpos.NewPrinter(prof)
if err != nil {
fatal("no se pudo crear la impresora: %v", err)
}
if *noGUI {
p.SetLogOutput(os.Stdout)
if *ticketDir == "" {
*ticketDir = "tickets"
}
}
if dir := *ticketDir; dir != "" {
p.SetOnTicket(func(t *escpos.Ticket) {
path, err := escpos.SaveTicketPNG(dir, t)
if err != nil {
p.Note("Error al guardar el ticket #%d: %v", t.Seq, err)
return
}
p.Note("Ticket #%d guardado en %s", t.Seq, path)
})
}
info := gui.Info{BusID: usbip.BusID}
if *lanAddr != "" {
srv, err := lan.Listen(*lanAddr, p)
if err != nil {
fatal("no se pudo abrir el puerto LAN %s: %v", *lanAddr, err)
}
info.LAN = srv.Addr().String()
p.Note("LAN: escuchando en %s (TCP RAW)", info.LAN)
}
if *usbAddr != "" {
srv, err := usbip.Listen(*usbAddr, usbip.DeviceFromProfile(prof), p)
if err != nil {
fatal("no se pudo abrir el puerto USB/IP %s: %v (¿está usbipd-win usando el 3240? prueba -usbip :3241)", *usbAddr, err)
}
info.USBIP = srv.Addr().String()
p.Note("USB/IP: escuchando en %s · conectar con: usbip attach -r <IP> -b %s", info.USBIP, usbip.BusID)
}
if *noGUI {
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt)
<-stop
return
}
gui.Run(p, info)
}
func fatal(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}