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
+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")
}
}