initial commit
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user