initial commit

This commit is contained in:
2026-09-30 10:22:42 +02:00
commit 3b0f5e365f
32 changed files with 4498 additions and 0 deletions
+106
View File
@@ -0,0 +1,106 @@
# Rol
Eres un ingeniero de sistemas senior experto en C, en programación de bajo
nivel sobre Windows, Linux y Android, y en el protocolo ESC/POS. Actúas como
instructor. Tu alumno soy yo: desarrollador backend experto en Go,
aprendiendo C.
# Objetivo
1. Aprender las entrañas de C: memoria, punteros, compilación, enlazado y
cómo se comunica un programa con el sistema operativo.
2. Construir YO MISMO una librería en C para imprimir en cualquier
impresora térmica ESC/POS, sin depender de los drivers del fabricante,
enviando los bytes en crudo.
Plataformas objetivo: Windows, Fedora, Debian y Android.
Empezamos por Windows.
Tengo impresoras físicas conectadas por USB y por LAN. Al empezar,
pregúntame marca y modelo de cada una.
# Entorno
- Windows: MSYS2, entorno UCRT64, con GCC, gdb y make
- Fedora en WSL: para compilar y probar la parte portable con
AddressSanitizer, UBSan y valgrind (en Windows con MinGW no funcionan)
- Estándar: C17
- Flags: -std=c17 -Wall -Wextra -Wpedantic -g
- Makefile único que funcione en Windows (MSYS2) y en Linux
# Arquitectura a construir
- Núcleo portable: buffer de bytes, constructor de comandos ESC/POS,
páginas de códigos, imágenes. Sin ninguna llamada al sistema operativo.
- Transportes por plataforma, aislados en ficheros propios:
red (Winsock en Windows, sockets POSIX en Linux/Android) y USB
(opciones por plataforma a evaluar juntos).
- API pública pensada para hacer bindings desde Go (cgo) más adelante:
handle opaco, códigos de error, sin estado global, y documentando en
cada función quién es el dueño de cada puntero y quién lo libera.
# Fases
1. Estructura del proyecto: cabeceras, ficheros .c, Makefile,
compilación separada y enlazado. Que compile en Windows y en Fedora.
2. Buffer dinámico con malloc/realloc/free (datos, longitud y capacidad,
el equivalente manual a un slice de Go).
3. Constructor de comandos: inicializar, texto, negrita, subrayado,
alineación, tamaño, avance de líneas, corte y apertura de cajón.
Guardar el resultado en un .bin e inspeccionarlo en hexadecimal.
4. Transporte LAN en Windows con Winsock, imprimiendo en la impresora real.
Después, sockets POSIX en Fedora, abstrayendo las diferencias.
5. Transporte USB en Windows: explícame las opciones (envío RAW al
spooler, WinUSB, libusb), sus pros y contras, y elegimos juntos.
6. Páginas de códigos para ñ, tildes y €.
7. Imágenes: comando raster GS v 0 y dithering. QR nativo (GS ( k) con
alternativa por imagen para impresoras que no lo soporten.
8. Lectura de estado de la impresora (DLE EOT): comunicación bidireccional.
9. USB en Linux y pruebas en Debian.
10. Android con el NDK.
11. Perfiles por modelo de impresora y autodetección (GS I).
12. API estable y bindings en Go con cgo.
# Cómo quiero que me enseñes
- NO escribas el código por mí. Explica el concepto, dame el enunciado del
siguiente paso y déjame implementarlo. Solo muestra código si te lo pido
o si es un fragmento mínimo para ilustrar algo nuevo del lenguaje.
- Compara siempre con Go: qué hacía Go por mí y qué tengo que hacer yo
ahora.
- Un concepto nuevo de C por paso, como máximo dos.
- Al revisar mi código: primero errores graves (fugas, desbordamientos,
comportamiento indefinido, errores sin comprobar), luego estilo.
Explica por qué es un problema, no solo cómo arreglarlo.
- Si hago algo "a lo Go" que en C es mala idea, dímelo directamente.
- Cuando toque una API del sistema (Winsock, spooler, USB, POSIX),
explícame qué hace el sistema operativo por debajo.
- Cuando necesite un comando ESC/POS, explícame su formato byte a byte
en vez de darme el código que lo genera.
- Pregúntame de vez en cuando para comprobar que lo he entendido.
- No avances de fase hasta que la actual funcione y, en la parte portable,
pase AddressSanitizer y valgrind en Fedora.
- Antes de imprimir en papel, comprobamos los bytes en un .bin con un
volcado hexadecimal, para no gastar papel en pruebas fallidas.
# Seguimiento
Mantén un fichero PROGRESO.md con la fase y paso actual, los conceptos de
C aprendidos, las decisiones de diseño tomadas y los puntos débiles a
repasar. Actualízalo al terminar cada paso y léelo al empezar cada sesión.
Mantén en `docs/` los apuntes de lo aprendido, en markdown y ordenados por
tema (`NN-tema.md`, nombres de fichero en inglés y contenido en español),
con un índice en `docs/README.md`. Cada vez que explique un concepto nuevo,
añádelo al fichero del tema que corresponda o crea uno nuevo y enlázalo en el
índice. Incluye siempre la comparación con Go.
# Comunicación
- En español
- Directo y conciso, sin relleno ni felicitaciones
# Convenciones
- Seguir los consejos de Código Limpio: código en inglés: variables, funciones,
archivos, todo, excepto los comentarios, documentación y la salida al usuario.
+283
View File
@@ -0,0 +1,283 @@
# Compilación y enlazado
## Las etapas de `gcc`
`gcc` no es un único programa: es un *driver* que llama a otros por turnos.
```
-E para aquí -S para aquí -c para aquí
│ │ │
.c ── cpp ──▶ .i ── cc1 ──▶ .s ── as ──▶ .o ── ld ──▶ .exe
preprocesador compilador ensamblador enlazador
```
- **Preprocesador**: procesa `#include` y `#define`. Solo manipula texto.
- **Compilador**: traduce C a ensamblador y comprueba tipos.
- **Ensamblador**: traduce el ensamblador a código máquina y produce un `.o`,
llamado *fichero objeto*.
- **Enlazador** (`ld`): junta los `.o` y las librerías en un ejecutable.
Sin flags de parada, gcc hace la cadena completa. Si no le das `-o`, el
ejecutable se llama `a.exe` en Windows y `a.out` en Linux.
**En Go**: `go build` hace lo mismo por dentro (`go tool compile` genera un
`.o` por paquete y `go tool link` los enlaza), pero no lo ves.
### Cómo compila Go
Go **no pasa por C**. Su compilador traduce Go a una representación
intermedia (SSA) y de ahí directamente a código máquina, con su propio
ensamblador y su propio enlazador. No usa `gcc` ni `ld`.
- El compilador de Go estuvo escrito en C hasta Go 1.5 (2015). Después se
tradujo a Go y ahora se compila a sí mismo.
- **Excepción, cgo**: si un fichero tiene `import "C"`, `go build` llama a
`gcc` para compilar la parte de C, y el resultado se enlaza con el de Go.
Así funcionarán los bindings de la fase 12.
- `go build -x` muestra los comandos que ejecuta por dentro, y
`go build -gcflags=-S` muestra el ensamblador generado.
- Un "hola mundo" en Go ocupa unos 2 MB porque incluye el *runtime*
(recolector de basura, planificador de goroutines). El de C es mucho más
pequeño porque usa la librería de C del sistema (en Windows, la UCRT, una
DLL).
### Enlazado estático frente a dinámico
- **Estático**: el código de las librerías se copia dentro del ejecutable.
Funciona en cualquier máquina sin instalar nada, pero ocupa más. Go lo hace
por defecto, y por eso es tan fácil de desplegar.
- **Dinámico**: el ejecutable guarda solo una referencia a la DLL (o `.so`),
y el sistema operativo la carga al arrancar el programa. Ocupa menos, pero
la DLL tiene que estar instalada en la máquina y en una versión compatible.
Es lo que hace gcc por defecto con la librería de C.
Comandos para medirlo en MSYS2:
- `objdump -p hello.exe | grep "DLL Name"`: lista las DLL de las que depende.
- `strip hello.exe`: quita símbolos e información de depuración.
- `gcc -static ...`: enlaza estáticamente también la librería de C.
- En Go, `go build -ldflags="-s -w"` es el equivalente de `strip`.
En MSYS2 UCRT64, `-static` no mete la librería de C: la UCRT es un componente
de Windows y solo existe como DLL. `-static` afecta a las demás librerías
(libgcc, winpthread...). Por eso un "hola mundo" ocupa lo mismo con y sin
`-static`.
### Tres formas de distribuir software
| Modelo | Ejemplos | Ventajas | Inconvenientes |
|---|---|---|---|
| **Todo dentro del ejecutable** (estático) | Go, herramientas "portables" | Un único fichero, no depende de nada | Ocupa más. Si una librería tiene un fallo de seguridad, **cada** programa que la lleva dentro hay que recompilarlo y redistribuirlo |
| **Ejecutable con sus DLL al lado** | Programas típicos de Windows en `Program Files` | El fabricante controla las versiones; puede actualizar una DLL por separado; varios ejecutables del mismo producto la comparten | Cada programa lleva su copia. Riesgo de *DLL hijacking*: Windows busca primero en la carpeta del ejecutable |
| **Librerías compartidas del sistema** | Linux con `dnf`/`apt`; en Windows, el "Visual C++ Redistributable" o el runtime de .NET | Una sola copia para todo el sistema: un parche de seguridad arregla todos los programas a la vez | Hay que instalar las dependencias en la versión correcta (*dependency hell*) |
Cuando compilas desde el código fuente en Linux, además de la librería
(`libfoo`) hay que instalar su paquete `-dev` o `-devel`, que trae **la
cabecera y el fichero para el enlazador**: las dos mitades del "import" de C.
Las licencias también cuentan. Una librería LGPL enlazada estáticamente
obliga a permitir que el usuario la vuelva a enlazar con otra versión, y por
eso se suele distribuir como DLL.
## Unidades de traducción
Cada `.c` se compila **por separado y a ciegas**: cuando gcc compila `hello.c`
no sabe nada de `escpos.c`. Cada `.c`, junto con todo lo que incluye, forma una
*unidad de traducción*.
- **Declaración**: `const char *escpos_version(void);`. Anuncia que la
función existe y con qué firma. Puede repetirse las veces que quieras.
- **Definición**: la función con su cuerpo `{ ... }`. Debe aparecer **una sola
vez** en todo el programa. Es la *regla de una definición*.
- **`static`** delante de una función la hace privada a su `.c`. Es lo más
parecido a la minúscula inicial de Go.
## Por qué compilar a `.o` y enlazar después
1. **Compilación incremental**: si solo cambia un `.c`, recompilas ese y vuelves
a enlazar. Go lo hace solo con su caché (`go env GOCACHE`); en C se encarga
el Makefile.
2. **Distribuir librerías sin el código fuente**: se entrega la cabecera más los
`.o` empaquetados (`.a` o `.dll`). Así será la librería que use cgo.
### Qué ve quien recibe los binarios
No recibe el `.c`, pero **no todo queda oculto**:
- **Nombres de los símbolos exportados** (`T`): el enlazador los necesita para
emparejarlos.
- **Cadenas literales**: salen en claro con `strings fichero.o`.
- **Con `-g`**: nombres de variables y tipos, rutas de los ficheros fuente y
la línea de código de cada instrucción (se ven con `objdump --dwarf=info`).
Para distribuir, se compila sin `-g` o se pasa `strip` al resultado.
- **El código máquina**: se desensambla con `objdump -d` y se puede
descompilar a un C aproximado con herramientas como Ghidra.
Entregar solo binarios dificulta leer el código, pero **no lo protege**. La
cabecera, en cambio, es texto que ve todo el mundo: es el contrato público.
Un `.o` solo sirve para la plataforma en la que se compiló (arquitectura,
sistema operativo, ABI). Un `.o` de Windows x86-64 no sirve en Android ARM:
hay que generar uno por cada plataforma.
**En Go**: las librerías se distribuyen casi siempre como código fuente. Los
paquetes solo binarios se eliminaron en Go 1.13. Un binario de Go se
desensambla igual que uno de C.
## Cabeceras e `#include`
En Go, `import` hace dos cosas a la vez. En C son dos pasos separados, y los
hacen dos programas distintos:
| | Para qué | Quién la usa | Cómo se indica |
|---|---|---|---|
| **Cabecera** (`.h`) | Conocer las firmas y comprobar tipos | Compilador | `#include` + `-I carpeta` |
| **Código** (`.o`, `.a`, `.dll`) | El cuerpo de las funciones | Enlazador | Pasarle el fichero en la línea de comandos |
- `#include` **no es un import**: el preprocesador copia y pega el texto del
`.h`, sin más. No trae código compilado.
- `#include <x.h>` busca en las carpetas de `-I` y luego en las del sistema,
**nunca en la carpeta del fichero que incluye**. Se usa para cabeceras del
sistema y de librerías externas.
- `#include "x.h"` busca primero en la carpeta del fichero que incluye y
después igual que `< >`. Se usa para las cabeceras del proyecto.
- No pongas rutas en el `#include` (`"include/escpos.h"`). Escribe
`"escpos.h"` y di dónde buscarlo con `-I include`.
- `-I` recibe una **carpeta**, no un fichero.
- Cada `.c` incluye **su propia cabecera**. Así el compilador comprueba que la
declaración y la definición coinciden. Si no la incluye y difieren, nadie lo
detecta, y el resultado es comportamiento indefinido.
- En las cabeceras van declaraciones, tipos y macros. **Nunca cuerpos de
funciones**: cada `.c` que incluyera la cabecera tendría su propia definición
y el enlazador daría `multiple definition`.
- Las funciones `static` van en el `.c`. Metidas en un `.h`, cada `.o` recibe
una copia privada: el código se duplica y aparecen avisos de "no usada".
### Encapsulación con cabeceras
La cabecera oculta **funciones** (lo que no declara y es `static` no existe
para el cliente), pero tiene límites:
- **Un `struct` definido en la cabecera no tiene campos privados.** Todos son
visibles y modificables. Además, su tamaño y el orden de sus campos pasan a
formar parte del contrato: si cambian, los programas que usan la librería
tienen que recompilarse.
- **No existe el nivel "paquete".** En Go, lo que no se exporta se comparte
entre todos los ficheros del paquete. En C, `static` es privado **al
fichero**. Para compartir algo entre dos `.c` de la librería tiene que ser
no-`static`, y entonces es visible para todo el mundo. Se resuelve con
cabeceras internas que no se entregan y controlando qué exporta la `.dll` o
la `.so`.
- **No hay protección en tiempo de ejecución.** Todo es memoria compartida:
con un puntero se puede escribir cualquier byte. La encapsulación la impone
el compilador, no el programa en marcha. En Go (`unsafe`, `reflect`) y en
Java (reflexión) pasa lo mismo.
La encapsulación de verdad se consigue con el **tipo opaco** (el *handle
opaco* de la API): la cabecera declara que el `struct` existe pero no lo
define, y la definición va en el `.c`. El cliente solo puede tener punteros a
él y llamar a funciones de la librería; ni siquiera conoce su tamaño ni sus
campos. Precio: hay que crearlo en memoria dinámica y cada acceso es una
llamada a función.
### *Include guard*
```c
#ifndef ESCPOS_H
#define ESCPOS_H
/* todo el contenido de la cabecera */
#endif
```
Es un `if` del preprocesador: la segunda vez que la cabecera se incluye en la
misma unidad de traducción, `ESCPOS_H` ya está definido y se salta el bloque
entero. El `#endif` tiene que ser la **última línea**; si no, lo que queda
fuera se copia siempre.
Una declaración de función repetida no da error, pero la definición de un
`struct` repetida sí (`redefinition of 'struct ...'`), y la librería tendrá
structs en la cabecera.
## `f()` frente a `f(void)`
En C17, `int f();` significa "parámetros **sin especificar**", así que
`f(1, 2, 3)` compila sin avisar. Para decir "sin parámetros" hay que escribir
`int f(void);`. En C23 ya significan lo mismo, pero este proyecto usa C17.
## Flags de gcc
| Flag | Etapa | Qué hace |
|---|---|---|
| `-std=c17` | compilación | Versión del lenguaje. Sin ella, gcc usa su dialecto con extensiones. |
| `-Wall -Wextra` | compilación | Activan avisos. `-Wall` no activa todos, pese al nombre. |
| `-Wpedantic` | compilación | Avisa de extensiones de gcc que no son C estándar. |
| `-g` | compilación | Añade información de depuración (secciones `.debug_*`) para gdb. |
| `-I carpeta` | preprocesador | Añade una carpeta donde buscar los `#include`. |
| `-E` / `-S` / `-c` | control | Paran tras preprocesar, compilar o ensamblar. |
| `-o nombre` | control | Nombre del fichero de salida. |
Todo menos `-o` se usa **al compilar**. Al enlazar solo se pasan los `.o`,
`-o` y, más adelante, las librerías (por ejemplo, `-lws2_32` para Winsock).
**En Go**: los avisos importantes son errores obligatorios (un import o una
variable sin usar no compilan), la versión va en `go.mod` y la información de
depuración se incluye siempre.
## `nm`: los símbolos de un `.o`
`nm fichero.o` lista los símbolos de un fichero objeto, es decir, los nombres
de sus funciones y variables globales. Las líneas que empiezan por `.` son
secciones internas.
| Letra | Significado |
|---|---|
| `T` | Definido aquí y **visible** para otros `.o` |
| `t` | Definido aquí pero **privado** (`static`) |
| `U` | **Usado** aquí pero definido en otro sitio: un hueco que rellenará el enlazador |
**El trabajo del enlazador es emparejar cada `U` con exactamente una `T`:**
- Ninguna `T` para un `U`: `undefined reference to 'x'`.
- Dos `T` con el mismo nombre: `multiple definition of 'x'`.
Para qué sirve `nm` en la práctica:
- Diagnosticar errores de enlazado: ver qué `.o` define un símbolo y cuál lo
necesita.
- Comprobar que la librería solo exporta (`T`) lo que está en la API pública y
que las funciones internas son `t`.
- Comprobar que una `.a` o `.dll` contiene lo que esperas antes de enlazarla
desde cgo.
Una declaración que no usas no crea ningún `U`: no genera hueco.
Pueden aparecer símbolos que no escribiste tú. Por ejemplo, `puts`: gcc
cambia `printf("%s\n", s)` por `puts(s)`. Y `__main`, que es el código de
arranque de MinGW.
## Errores: compilador frente a enlazador
- **Compilador**: el mensaje empieza por `fichero.c:línea:columna:`, porque
está leyendo código fuente.
- **Enlazador**: el mensaje menciona `ld.exe` o `collect2`, además de ficheros
`.o` y nombres de símbolo. A esa etapa ya no llega el código fuente.
## Warning frente a error
- `warning:` es un aviso: el `.o` se genera igualmente.
- `error:` significa que **no se genera nada**.
Cuando gcc falla, **no borra el `.o` ni el `.exe` anteriores**. Puedes acabar
ejecutando un binario viejo creyendo que es el nuevo. Para evitarlo:
- Busca `error:` en la salida.
- Comprueba `echo $?` justo después del comando: `0` es éxito y cualquier otro
valor es fallo.
- `make` se detiene en cuanto un comando falla.
## Varios
- En bash, un programa de la carpeta actual se ejecuta con `./hello.exe`. Bash
no busca en la carpeta actual por seguridad.
+60
View File
@@ -0,0 +1,60 @@
# Punteros y cadenas
## En C no hay tipo `string`
En Go, `string` es un tipo propio: por dentro guarda un puntero y una
longitud, y lo manejas como un valor.
En C, una cadena es solo una secuencia de bytes en memoria terminada en `'\0'`.
La longitud **no se guarda en ningún sitio**: para saberla hay que recorrer la
cadena hasta encontrar el `'\0'`.
```
"1.0.0" → '1' '.' '0' '.' '0' '\0' (6 bytes, no 5)
```
Los literales como `"1.0.0"` viven en **memoria de solo lectura**. Si
intentas escribir en ellos, el comportamiento es indefinido; en la práctica,
el programa se cierra.
## `const char *`
Una función que "devuelve una cadena" en realidad devuelve **la dirección de
su primer byte**:
```c
const char *version = escpos_version();
```
- `char`: en esa dirección hay caracteres.
- `*`: `version` es un puntero; guarda una dirección, no un carácter.
- `const`: a través de este puntero no se pueden modificar los caracteres.
`char` es un **entero de 1 byte**. Si guardas un puntero en un `char`, pierdes
la dirección. Por eso el error dice `makes pointer from integer`.
## Los dos significados de `*`
| Dónde aparece | Qué significa |
|---|---|
| En una **declaración**: `const char *p` | "`p` es un puntero" |
| En una **expresión**: `*p` | "el valor que hay en la dirección de `p`" (desreferenciar) |
Si `p` apunta a `"1.0.0"`, `*p` es solo el `'1'`.
**En Go**: es lo mismo que `var p *T` frente a `*p`.
## `printf` y el formato
El primer argumento de `printf` es el **formato**. Nunca pases ahí una cadena
variable:
```c
printf(s); /* mal */
printf("%s\n", s); /* bien */
```
Si `s` contuviera un `%`, `printf` iría a leer argumentos que no existen, lo
que es comportamiento indefinido y un agujero de seguridad clásico
(*format string*). `%s` significa "imprime los bytes desde esta dirección hasta
el `'\0'`".
+200
View File
@@ -0,0 +1,200 @@
# La memoria de un proceso y el sistema operativo
## Memoria virtual
Cada proceso tiene su propio **espacio de direcciones virtual**. Las
direcciones que guarda un puntero no son direcciones de la RAM física: son
virtuales.
- La **MMU**, un componente del procesador, traduce cada dirección virtual a
una física en cada acceso, usando las **tablas de páginas** del proceso.
- Esas tablas las crea y mantiene el **núcleo** (*kernel*), y un proceso no
puede modificarlas.
- La memoria se gestiona en **páginas** de 4 KB. Cada página tiene permisos:
lectura, escritura, ejecución, y si es accesible desde modo usuario o solo
desde el núcleo.
Consecuencia: la misma dirección, por ejemplo `0x7ff6a000`, en dos procesos
distintos apunta a sitios físicos diferentes. **Un proceso no puede ni
siquiera nombrar la memoria de otro.**
## Qué pasa al acceder a una dirección no válida
1. La MMU no encuentra la traducción, o los permisos no lo permiten (por
ejemplo, escribir en una página de solo lectura).
2. El procesador genera una excepción de fallo de página y cede el control al
núcleo.
3. El núcleo decide: o era legítimo (una página que tenía en disco, por
ejemplo), o es un error y mata el proceso.
- Windows: `Access violation` (código `0xC0000005`).
- Linux: señal `SIGSEGV` (`Segmentation fault`).
Así fallaría escribir en `"1.0.0"`: está en una página de solo lectura.
## Modo usuario y modo núcleo
- Tu programa se ejecuta en **modo usuario** (*ring 3*). Hay instrucciones
que no puede ejecutar, y no puede acceder a las páginas del núcleo ni al
hardware directamente.
- El núcleo se ejecuta en **modo núcleo** (*ring 0*), con acceso a todo.
- Para cualquier cosa fuera de su memoria (ficheros, red, USB, otros
procesos), el programa hace una **llamada al sistema** (*syscall*): pide al
núcleo que lo haga por él, y el núcleo comprueba permisos.
Por eso los transportes del proyecto (Winsock, spooler, WinUSB, sockets
POSIX) son todos APIs del sistema: ningún programa en modo usuario habla
directamente con la tarjeta de red ni con el USB.
## Leer la memoria de otro proceso
Solo a través del núcleo y con permisos:
- **Windows**: `OpenProcess` con `PROCESS_VM_READ` y después
`ReadProcessMemory`. Se permite sobre procesos del mismo usuario; para
procesos del sistema hacen falta permisos de administrador
(`SeDebugPrivilege`).
- **Linux**: `ptrace`, `process_vm_readv` o `/proc/<pid>/mem`. Hace falta el
mismo usuario, y además lo limita `ptrace_scope`; con `root` o
`CAP_SYS_PTRACE`, de cualquiera.
Así funcionan los depuradores como gdb.
## Leer la memoria física o la del núcleo
Solo desde código que se ejecute en modo núcleo, es decir, un **driver**. En
Windows, los drivers tienen que estar firmados. En Linux, `/dev/mem` está muy
restringido. Los fallos de CPU como Meltdown (2018) fueron graves justo porque
permitían saltarse esa barrera.
## El peligro real en C: tu propia memoria
C no impide que un puntero lea **cualquier parte de tu propio proceso**.
Salirse de un array no provoca un fallo si la página de al lado está mapeada:
lee o machaca en silencio otros datos tuyos. Es comportamiento indefinido.
- Heartbleed (OpenSSL, 2014) era exactamente esto: una lectura fuera de los
límites que devolvía al atacante memoria del propio servidor, con claves y
contraseñas incluidas.
## Cómo actúa el software malicioso
Casi nunca rompe el aislamiento de memoria. Las vías habituales son:
1. **Usar permisos legítimos.** Un programa que ejecutas se ejecuta con
**tus** permisos: puede leer, cifrar o enviar tus ficheros con las APIs
normales. El ransomware no necesita ningún truco de memoria. El sistema
operativo aísla unos procesos de otros y al núcleo de los usuarios, pero no
protege tus ficheros de un programa que tú mismo has lanzado.
2. **Explotar fallos de memoria en programas legítimos.** Un servidor escrito
en C recibe datos preparados por el atacante que desbordan un buffer, y con
eso toma el control de ese proceso. Aquí es donde la falta de comprobaciones
de C importa. Según Microsoft y Google, en torno al 70 % de sus
vulnerabilidades graves son fallos de seguridad de memoria.
3. **Escalar privilegios.** Aprovechar un fallo en el núcleo o en un driver
para pasar de usuario a administrador o a modo núcleo.
Defensas que usan el procesador y el sistema operativo:
- **DEP/NX**: las páginas de datos no son ejecutables.
- **ASLR**: el sistema coloca el código y los datos en direcciones aleatorias
en cada ejecución.
- **Canarios de pila** (`-fstack-protector`): el compilador detecta si alguien
ha sobrescrito la pila.
Estas defensas dificultan el ataque, pero no corrigen el fallo de fondo.
**Para la librería**: tratará datos que no controla, como las respuestas de la
impresora (`DLE EOT`, `GS I`) o el texto que llegue desde Go. Toda longitud
que venga de fuera se comprueba antes de copiar nada.
### Drivers del núcleo frente a la librería del proyecto
"Driver" es una palabra con varios significados.
- **Driver del núcleo**: un módulo que se carga *dentro* del núcleo, en modo
núcleo. Habla con el hardware directamente (registros, interrupciones, DMA)
y ofrece el dispositivo a los programas de usuario de forma controlada.
Ejemplos: el de la tarjeta de red, `usbprint.sys`, `winusb.sys`, `usblp` en
Linux.
- **La librería del proyecto**: código en **modo usuario** que conoce el
*protocolo* de la impresora (ESC/POS) y genera y envía los bytes a través de
lo que ofrecen los drivers del núcleo.
La pila completa al imprimir:
```
programa en Go
└─ librería escpos (modo usuario) ← lo que construimos: sabe ESC/POS
├─ LAN: Winsock / sockets ──▶ syscall ──▶ pila TCP/IP ──▶ driver de red
└─ USB: spooler / WinUSB / libusb ──▶ syscall ──▶ usbprint/winusb ──▶ controlador USB
└──── modo núcleo ────┘
```
Los drivers del núcleo solo saben mover bytes y no entienden ESC/POS. La
librería entiende ESC/POS, pero no puede tocar el hardware.
No es un "driver de mentira": es otra capa. Los drivers de impresora de
Windows que instalan los fabricantes también son, en su mayor parte, código
en modo usuario que convierte el documento al lenguaje de la impresora;
debajo usan los mismos drivers del núcleo. Microsoft tiene incluso un marco
para escribir drivers en modo usuario (UMDF).
**En Go**: es como `net/http`. No implementa TCP, que es cosa del núcleo:
habla HTTP sobre los sockets que le da el sistema. La librería hace lo mismo
con ESC/POS.
### Caso real: CrowdStrike (19 de julio de 2024)
- **Qué es**: Falcon, el antivirus/EDR de CrowdStrike, tiene en Windows un
**driver en modo núcleo** que se carga al arrancar el sistema. Está en el
núcleo para poder vigilar todo el sistema y para que un malware no pueda
desactivarlo.
- **Qué pasó**: una actualización de contenido (un fichero de *datos*, el
"Channel File 291", no código nuevo) definía 21 campos de entrada. El
código del driver que lo procesaba solo proporcionaba 20. Al acceder al
campo 21 hizo una **lectura fuera de límites**, en modo núcleo.
- **Por qué fue tan grave**: en modo usuario, el núcleo mata el proceso y el
resto del sistema sigue funcionando. En modo núcleo no hay nadie por encima
que pueda recoger el error, así que Windows se detiene entero (pantalla
azul). Como el driver se carga al arrancar, el equipo caía **en cada
reinicio**. Microsoft estimó unos 8,5 millones de equipos afectados:
aerolíneas, hospitales, bancos.
- **Arreglo**: manual, equipo por equipo: arrancar en modo seguro y borrar el
fichero. Con BitLocker hacía falta además la clave de recuperación de cada
disco.
- **Fallos encadenados**:
1. El driver confió en que los datos de fuera tenían el tamaño esperado.
2. El validador de contenido tenía su propio fallo y dio el fichero por
bueno.
3. La actualización se envió a todos los clientes a la vez, sin
despliegue progresivo.
- **Consecuencia**: Microsoft anunció después planes para que los antivirus
puedan funcionar fuera del núcleo.
**En Go**, ese acceso habría sido un `panic: index out of range` en un
proceso. En C dentro del núcleo, millones de pantallas azules.
**Para el proyecto**: todo lo que haremos (Winsock, spooler, WinUSB, libusb)
se ejecuta en modo usuario. Un fallo en la librería tumba el programa que la
usa, no la máquina.
### cgo: Go y C comparten proceso
Con cgo, la librería en C se ejecuta **dentro del mismo proceso** que el
programa en Go, en el mismo espacio de direcciones. Las comprobaciones de Go
solo protegen el código Go: el código C puede escribir en cualquier parte del
proceso, incluidas la memoria de Go y las estructuras de su runtime.
- Un desbordamiento en la librería puede corromper datos de Go y provocar un
fallo mucho más tarde, en código Go correcto, lejos de la causa.
- Por LAN, cualquier equipo de la red puede hacerse pasar por la impresora en
`IP:9100` y enviar respuestas preparadas. Si la librería las copia sin
comprobar la longitud, ese equipo puede tomar el control del programa en Go.
**La garantía de seguridad de memoria de Go termina en la frontera de cgo.**
**En Go**: el modelo de procesos es el mismo, porque lo impone el sistema
operativo y no el lenguaje. La diferencia es que Go comprueba los límites de
cada slice y cada puntero `nil`, y provoca un `panic` **antes** del acceso
indebido. C no comprueba nada; para detectar estos errores están
AddressSanitizer y valgrind.
+95
View File
@@ -0,0 +1,95 @@
# Go escrito con mentalidad de C
Experimento: el mismo generador de tickets ESC/POS escrito en Go de dos
formas, midiendo el resultado (i9-9900K, Go 1.26, 30 líneas por ticket). Las
dos versiones producen exactamente los mismos bytes.
| | ns/ticket | bytes reservados/ticket | reservas/ticket |
|---|---|---|---|
| Hábitos de JS (`+=` en strings, `fmt.Sprintf`, `[]*Item`) | ~9.600 | 14.378 | 126 |
| Hábitos de C (un buffer reutilizado, `strconv.Append*`) | ~550 | 0 | 0 |
## Por qué
- **Los strings son inmutables.** `s += x` crea un string nuevo y copia todo
lo anterior en él. En un bucle, cada iteración copia más bytes que la
anterior: el coste crece con el cuadrado del tamaño.
- **`fmt.Sprintf`** reserva memoria para el resultado y usa reflexión.
`strconv.AppendInt` escribe directamente en tu buffer.
- **Reutilizar el buffer**: `buf = buf[:0]` pone la longitud a 0 pero
conserva la capacidad, es decir, la memoria ya reservada. Tras el primer
ticket no se vuelve a reservar nada. Es lo que hará el buffer de la fase 2.
## Padding: el orden de los campos cambia el tamaño
El procesador exige que un `int64` empiece en una dirección múltiplo de 8. El
compilador rellena con huecos (*padding*) para cumplirlo:
- `{bool, int64, bool, int64, bool}` ocupa **40 bytes**.
- `{int64, int64, bool, bool, bool}` ocupa **24 bytes**.
Con un millón de elementos son 38 MB frente a 22 MB. En C pasa exactamente lo
mismo.
## Un slice es `{puntero, longitud, capacidad}`
```go
header := make([]byte, 0, 16)
header = append(header, 0x1b, '@')
a := append(header, 'A')
b := append(header, 'B') // a también termina en 'B'
```
Hay capacidad libre, así que `append` no copia: escribe en el mismo array.
`a` y `b` comparten memoria. Quien piensa en punteros lo ve venir; quien
piensa en arrays de JS, no.
## Dinero en `float64`
`10 × 0.10` da `0.9999999999999999`. El `number` de JS es un `float64`. Para
dinero se usan enteros en céntimos.
## El precio de la versión rápida
`BuildTicket` devuelve una **vista del buffer interno**, que deja de ser
válida en la siguiente llamada. Hay que documentar quién es el dueño de esa
memoria. Es exactamente la regla de la API de la librería: documentar en cada
función quién es dueño de cada puntero.
## Tercera versión: una IA a partir de una spec escueta
Spec: "cabecera en negrita, una línea por producto, total, corte, precios con
dos decimales, tests". El código resultante es idiomático (`bytes.Buffer`,
`fmt.Fprintf`, dinero en céntimos) y **pasa todos los criterios de
aceptación**.
| | ns/ticket | bytes/ticket | reservas/ticket |
|---|---|---|---|
| IA con spec escueta | ~8.300 | 3.186 | 99 |
Casos que la spec no menciona:
| Caso | IA | Estilo C | Estilo JS |
|---|---|---|---|
| Descuento de −1,50 | `-1.-50` ❌ | `-1.+0` ❌ | `-1.50` ✅ (de casualidad) |
| `é`, `ñ`, `€` | UTF-8 ❌ | UTF-8 ❌ | UTF-8 ❌ |
| Nombre más largo que el papel (48/32 columnas) | no lo trata | no lo trata | no lo trata |
- **Negativos**: en Go (y en C) el resto de un número negativo es negativo:
`-150 % 100 == -50`. Las versiones con enteros lo formatean mal.
- **Codificación**: la impresora no entiende UTF-8. `é` se envía como `C3 A9`
y en papel sale como dos símbolos extraños. Hay que convertir a la página de
códigos de la impresora (fase 6).
Conclusión: el resultado nunca es mejor que la spec más la capacidad de quien
lo revisa. Las decisiones que no se piden las toma el modelo "por defecto", y
solo las detecta quien sabe que existen. Una spec de experto incluiría:
presupuesto de reservas de memoria, quién es dueño del buffer, importes
negativos, página de códigos y ancho de línea según el modelo de impresora.
## Cuándo no merece la pena
Si el ticket se genera una vez por venta, 9 µs frente a 0,5 µs da igual. La
versión rápida compensa en rutas calientes: miles de peticiones por segundo o
bucles sobre millones de elementos. Primero se mide (`go test -bench
-benchmem`, `pprof`) y después se optimiza.
+103
View File
@@ -0,0 +1,103 @@
# Frameworks y librerías del ecosistema C/C++
## Qt (C++, no C)
- Framework multiplataforma en **C++**: interfaces gráficas, red, hilos,
bases de datos, impresión, multimedia. Funciona en Windows, Linux, macOS,
Android, iOS y sistemas embebidos.
- Lo usan KDE, OBS Studio, Telegram Desktop, VirtualBox y muchos sistemas de
coche y de punto de venta.
- **Señales y slots**: un objeto emite un evento y otros lo reciben. Para
implementarlo, Qt tiene un precompilador propio (`moc`) que genera código
C++ adicional antes de compilar.
- **QML**: lenguaje declarativo, parecido a JS, para las interfaces modernas.
- **Licencia**: LGPLv3 o comercial (algunos módulos solo GPL o comercial). Con
LGPL hay que permitir que el usuario sustituya las librerías de Qt, y por
eso las aplicaciones Qt se distribuyen con sus DLL al lado.
- **Relación con este proyecto**: una aplicación Qt imprime con `QPrinter`, que
pasa por el driver del fabricante y el spooler. Para ESC/POS en crudo
(cajón, corte, estado) necesitaría una librería como esta.
## Por qué en C casi no hay "frameworks"
- La librería estándar de C es mínima: no trae red, ni contenedores, ni JSON,
ni HTTP. Los hilos (`threads.h`) llegaron en C11 y son opcionales.
- No hay gestor de paquetes oficial ni genéricos.
- Cultura resultante: **librerías pequeñas que se combinan**. Muchas son de
"una sola cabecera" o de un solo `.c`, para poder copiarlas en el proyecto
sin más.
**En Go**: la librería estándar ya trae `net/http`, `encoding/json`, `sync`,
etc., más `go get`.
## Librerías C relevantes
| Librería | Qué es | Relación |
|---|---|---|
| **GTK** + **GLib/GObject** | Interfaz gráfica de GNOME y GIMP. GObject implementa orientación a objetos a mano sobre C | Ejemplo de cómo simular clases e interfaces en C |
| **SDL** | Ventanas, gráficos, audio y entrada; multiplataforma, incluido Android | Juegos y multimedia |
| **LVGL** | Interfaces gráficas para microcontroladores con pantalla | Terminales de punto de venta embebidos |
| **libuv** | Bucle de eventos multiplataforma | Es el motor de Node.js: lo que hay debajo de JS |
| **libcurl** | Cliente HTTP y muchos protocolos más | |
| **SQLite** | Base de datos completa en un solo `.c` | Código C de referencia, muy bien probado |
| **libusb** | Acceso a USB desde modo usuario | Candidata para las fases 5 y 9 |
| **zlib**, **OpenSSL** | Compresión y criptografía | |
| **Unity**, **cmocka** | Frameworks de tests para C | Para probar la librería |
| **stb** | Colección de librerías de una sola cabecera (por ejemplo, `stb_image` para cargar PNG o JPEG) | Posible ayuda en la fase 7 (imágenes) |
## Interfaces en C
Una `interface` de Go se implementa en C como un `struct` de **punteros a
función**. Así funcionan GObject, los drivers de Linux y, en la fase 4, la
abstracción de transportes de la librería (LAN, USB) detrás de una misma API.
### Piezas
- **Puntero a función**: las funciones también están en memoria (en la
sección `.text`; la dirección que muestra `nm` junto a cada `T`). Un
puntero a función guarda esa dirección y permite llamarla.
Sintaxis: `double (*area)(const void *self);`.
- **`void *`**: un puntero "a cualquier cosa", sin tipo. Hace el papel del
receptor del método. C no tiene métodos: el objeto se pasa explícitamente.
Go hace lo mismo por dentro: `c.Area()` se compila como `Area(c)`.
- **vtable**: un `struct` con los punteros a función de un tipo concreto. Se
define una vez por tipo y la comparten todas sus instancias.
- **Valor de la "interfaz"**: dos punteros, `{vtable, datos}`.
### Cómo es una interfaz de Go por dentro
En el runtime de Go, una interfaz con métodos es exactamente eso:
```go
type iface struct {
tab *itab // tipo concreto + punteros a sus métodos
data unsafe.Pointer // el valor concreto
}
```
Por eso una interfaz ocupa 16 bytes en 64 bits. Y por eso existe la trampa
del `nil`:
```go
var p *MyError = nil
var err error = p
err != nil // true: tab apunta al tipo *MyError, aunque data sea nil
```
Una interfaz solo es `nil` si **los dos** campos son `nil`.
### Qué hace Go por ti y qué haces tú en C
| Go | C |
|---|---|
| Comprueba en compilación que el tipo implementa la interfaz | Nadie lo comprueba: rellenas la vtable a mano |
| Construye el `itab` automáticamente | Declaras la vtable como `static const` |
| El receptor tiene su tipo | `void *`: si pasas el objeto equivocado, comportamiento indefinido |
| Un método que falta no compila | Un puntero a `NULL` en la vtable provoca un fallo al llamarlo |
### Ejemplo real: los drivers de Linux
Cada driver rellena un `struct file_operations` con punteros a sus funciones
`open`, `read`, `write`... Cuando un programa hace `write()` sobre
`/dev/usb/lp0`, el núcleo llama a `usblp_write` a través de ese puntero. Es
el `io.Writer` del núcleo de Linux.
+19
View File
@@ -0,0 +1,19 @@
# Apuntes
Ordenados por tema, en el orden en que se ven en el proyecto.
1. [Compilación y enlazado](01-compilation-and-linking.md): etapas de gcc,
cómo compila Go, unidades de traducción, cabeceras e `#include`,
encapsulación y tipos opacos, *include guards*, flags, qué ve quien recibe
los binarios, `nm` y errores del enlazador.
2. [Punteros y cadenas](02-pointers-and-strings.md): cadenas terminadas en
`'\0'`, `const char *`, los dos significados de `*` y el formato de `printf`.
3. [La memoria de un proceso](03-process-memory.md): memoria virtual, fallos
de página, modo usuario y modo núcleo, llamadas al sistema, cómo se lee la
memoria de otro proceso, y por qué el peligro real está en la tuya.
4. [Go con mentalidad de C](04-go-with-c-mindset.md): benchmark de dos
estilos, reservas de memoria, padding, slices que comparten memoria,
dinero en float y el precio de la versión rápida.
5. [Frameworks y librerías](05-libraries-and-frameworks.md): Qt, por qué en C
casi no hay frameworks, librerías C relevantes para el proyecto e
interfaces con punteros a función.
+117
View File
@@ -0,0 +1,117 @@
# Emulador de impresora térmica ESC/POS
Impresora térmica virtual para probar la librería sin papel ni ruido. Recibe
bytes ESC/POS por **LAN** (TCP RAW, puerto 9100) y por **USB** (dispositivo
USB real exportado por USB/IP), dibuja el papel que saldría y responde a los
comandos de estado como una impresora de verdad.
## Compilar y ejecutar
```sh
cd emulator
go build -o escpos-emu.exe .
./escpos-emu.exe # perfil 80 mm, LAN :9100, USB/IP :3240
./escpos-emu.exe -perfil 58mm # clon barato: sin cuchilla, sin QR, GS I limitado
./escpos-emu.exe -nogui # sin ventana: registro por consola, tickets a ./tickets/*.png
```
| Opción | Por defecto | Significado |
|-------------|-------------|-------------------------------------------------------|
| `-lan` | `:9100` | Puerto TCP RAW. Vacío (`-lan ""`) lo desactiva |
| `-usbip` | `:3240` | Servidor USB/IP. Vacío lo desactiva |
| `-perfil` | `80mm` | `80mm` o `58mm` |
| `-nogui` | no | Modo consola |
| `-tickets` | — | Carpeta donde guardar cada ticket cortado como PNG |
La primera vez, el firewall de Windows preguntará si permite las conexiones:
acéptalo en redes privadas si vas a conectar desde WSL u otra máquina.
## La ventana
- **Centro**: el papel. Arriba, los tickets ya cortados; abajo, el papel que
sigue dentro de la impresora. La línea roja es la cuchilla.
- **Derecha**: cada comando recibido con sus bytes en hexadecimal y su
significado. Verde: texto. Azul: respuestas al host. Naranja: avisos.
- **Izquierda**: interruptores de estado físico (tapa, papel, cuchilla,
offline), cajón, captura de bytes a `.bin` y reinicio.
## LAN
Cualquier cliente TCP sirve. Abre una conexión a `IP:9100`, escribe los bytes
y, si has enviado un comando de estado, lee la respuesta por el mismo socket.
Desde WSL con NAT, la IP de Windows es la puerta de enlace:
`ip route show default | awk '{print $3}'`.
## USB (USB/IP)
El emulador actúa como servidor USB/IP y publica un dispositivo `1209:0001`
de clase impresora (07/01/02, bidireccional) con endpoint bulk OUT 0x01 y bulk
IN 0x82, de 64 bytes. El sistema operativo lo ve como una impresora USB
enchufada físicamente y carga su driver habitual.
**Fedora / Debian (WSL o nativo)**
```sh
sudo dnf install usbip # Debian: sudo apt install usbip
sudo modprobe vhci-hcd
usbip list -r <IP-de-Windows>
sudo usbip attach -r <IP-de-Windows> -b 1-1
lsusb # 1209:0001 EMULADOR ESCPOS-EMU-80
ls /dev/usb/lp* # el driver usblp crea /dev/usb/lp0
sudo usbip detach -p 0
```
**Windows**
Instala el cliente [usbip-win2](https://github.com/vadimgrn/usbip-win2) (lee
en su README los requisitos de firma del driver). Después:
```bat
usbip attach -r 127.0.0.1 -b 1-1
usbip port
usbip detach -p <puerto>
```
Windows carga `usbprint.sys` y crea un puerto `USB001`, igual que con una
impresora real. Para acceder con WinUSB o libusb hay que cambiarle el driver
(por ejemplo con Zadig), como con cualquier dispositivo USB.
Si `usbipd-win` ya ocupa el puerto 3240, usa `-usbip :3241` y en el cliente
indica el puerto (`usbip --tcp-port 3241 ...` en Linux).
## Respuestas de estado
| Comando | Respuesta (1 byte salvo indicación) |
|---------------|-------------------------------------------------------------------------|
| `DLE EOT 1` | `0x12` + bit 2 cajón abierto + bit 3 offline |
| `DLE EOT 2` | `0x12` + bit 2 tapa abierta + bit 5 sin papel + bit 6 error |
| `DLE EOT 3` | `0x12` + bit 3 error de cuchilla |
| `DLE EOT 4` | `0x12` + bits 2-3 papel casi agotado + bits 5-6 sin papel |
| `GS r 1` | bits 0-1 casi agotado, bits 2-3 sin papel |
| `GS I 1/2/3` | ID de modelo / tipo / versión |
| `GS I 65..69` | `0x5F` + texto + `0x00` (solo perfil 80mm; el 58mm no responde) |
| `GS a n` | activa el estado automático (4 bytes en cada cambio) |
Con la tapa abierta, sin papel, con error de cuchilla u offline, la impresora
**deja de imprimir** y guarda los datos en el buffer; `DLE EOT` y `DLE DC4 1`
se siguen atendiendo al momento. Al resolver el problema se imprime lo
pendiente.
## Comportamientos reales que conviene conocer
- **La cuchilla está 12 mm por encima del cabezal.** `GS V 0` corta donde
está la cuchilla, así que las últimas líneas quedan en el siguiente ticket.
`GS V 65 n` avanza hasta la cuchilla y `n` puntos más antes de cortar.
- **`ESC a` solo se aplica al principio de una línea.** Si llega a mitad de
línea, se aplica en la siguiente.
- **Los comandos pueden llegar partidos** entre paquetes TCP o transferencias
USB. El emulador espera a tener el comando completo, como el firmware.
- **Ancho imprimible**: 576 puntos en 80 mm y 384 en 58 mm (8 puntos/mm).
## Qué no emula
Modo página, códigos de barras (`GS k` dibuja un recuadro con los datos),
PDF417 y otros símbolos 2D distintos de QR, caracteres definidos por el
usuario, imágenes NV/descargables, impresión boca abajo, y control de flujo
(el buffer no se llena nunca).
Binary file not shown.
+19
View File
@@ -0,0 +1,19 @@
module escpos-emulator
go 1.26.3
require (
gioui.org v0.10.3
golang.org/x/image v0.46.0
golang.org/x/text v0.42.0
rsc.io/qr v0.2.0
)
require (
gioui.org/shader v1.0.9 // indirect
github.com/go-text/typesetting v0.3.5 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.48.0 // indirect
)
+27
View File
@@ -0,0 +1,27 @@
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
gioui.org v0.10.3 h1:ZiJ4CRvmPQEl2Ee0lK6JQ9WepiA628YmbjibiBVnBTw=
gioui.org v0.10.3/go.mod h1:x8MAOooc/v4UUaB5o6BS/FpYdxnzh3LBvZlTBmYpnlQ=
gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org/shader v1.0.9 h1:XxnqIfmClWpN49kizxH2W0JcCFrrEP4q3jZmNYaltbs=
gioui.org/shader v1.0.9/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=
github.com/go-text/typesetting v0.3.5/go.mod h1:XZO1hD+nQVyvVa5IicQk7FsCa4PFQaJ2soWAP1f//68=
github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2It5K75XkavhTiCKExUfVaVDS1feBnLCru5qeoY=
github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80=
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ=
golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
+36
View File
@@ -0,0 +1,36 @@
package escpos
import (
"fmt"
"image/png"
"os"
"path/filepath"
"time"
)
// SaveTicketPNG guarda el ticket en dir y devuelve la ruta del fichero.
func SaveTicketPNG(dir string, t *Ticket) (string, error) {
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", err
}
path := filepath.Join(dir, fmt.Sprintf("ticket-%s-%04d.png", t.Time.Format("20060102-150405"), t.Seq))
f, err := os.Create(path)
if err != nil {
return "", err
}
if err := png.Encode(f, t.Image); err != nil {
f.Close()
return "", err
}
return path, f.Close()
}
// SaveCapture guarda todos los bytes recibidos en un .bin dentro de dir.
func (p *Printer) SaveCapture(dir string) (string, int, error) {
data := p.Capture()
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", 0, err
}
path := filepath.Join(dir, "captura-"+time.Now().Format("20060102-150405")+".bin")
return path, len(data), os.WriteFile(path, data, 0o644)
}
+777
View File
@@ -0,0 +1,777 @@
package escpos
import (
"bytes"
"strings"
)
const (
nul = 0x00
ht = 0x09
lf = 0x0A
ff = 0x0C
cr = 0x0D
dle = 0x10
can = 0x18
esc = 0x1B
fs = 0x1C
gs = 0x1D
)
// step interpreta el comando que empieza en b[0]. Devuelve los bytes
// consumidos, o 0 si el comando está incompleto y hay que esperar más datos.
func (p *Printer) step(s *Session, b []byte) int {
switch c := b[0]; {
case c >= 0x20:
return p.text(s, b)
case c == lf:
p.cmd(s, b[:1], "LF — imprime la línea y avanza %d puntos", max(p.st.lineSpacing, p.lineHeight()))
p.printLine(p.st.lineSpacing)
case c == cr:
p.cmd(s, b[:1], "CR — ignorado (esta impresora no hace retorno automático)")
case c == ht:
p.tab()
p.cmd(s, b[:1], "HT — tabulador: columna en punto %d", p.line.x)
case c == esc:
return p.escCommand(s, b)
case c == gs:
return p.gsCommand(s, b)
case c == dle:
return p.dleCommand(s, b)
case c == fs:
return p.fsCommand(s, b)
case c == ff || c == can:
p.cmd(s, b[:1], "0x%02X — solo tiene efecto en modo página: ignorado", c)
default:
p.warnRaw(s, b[:1], "Byte de control 0x%02X sin significado: ignorado", c)
}
return 1
}
func (p *Printer) text(s *Session, b []byte) int {
n := 0
for n < len(b) && b[n] >= 0x20 {
n++
}
var sb strings.Builder
for _, c := range b[:n] {
r := p.decode(c)
p.addChar(r)
sb.WriteRune(r)
}
p.addLog(s, LogText, b[:n], "Texto: «"+sb.String()+"»")
return n
}
// Longitud total de los comandos ESC de longitud fija.
var escFixedLen = map[byte]int{
'@': 2, 'E': 3, 'G': 3, '-': 3, 'a': 3, '!': 3, 'M': 3, 'd': 3, 'J': 3,
'2': 2, '3': 3, ' ': 3, 't': 3, 'R': 3, 'p': 5, 'i': 2, 'm': 2, '$': 4,
'\\': 4, '{': 3, 'V': 3, 'c': 4, '=': 3, 'v': 2, 'u': 3, 'U': 3, 'L': 2,
'S': 2, 'T': 3, 'W': 10, 'r': 3, 'e': 3, '%': 3, '?': 3, 'K': 3,
}
// Longitud total de los comandos GS de longitud fija.
var gsFixedLen = map[byte]int{
'!': 3, 'B': 3, 'L': 4, 'W': 4, 'I': 3, 'r': 3, 'a': 3, 'h': 3, 'w': 3,
'H': 3, 'f': 3, 'P': 4, 'b': 3, '/': 3, '$': 4, '\\': 4, 'T': 3, 'E': 3,
':': 2, '^': 5, 'j': 3, 'g': 3, 'z': 3,
}
func (p *Printer) escCommand(s *Session, b []byte) int {
if len(b) < 2 {
return 0
}
fixed := escFixedLen
if n, ok := fixed[b[1]]; ok && len(b) < n {
return 0
}
switch b[1] {
case '@':
p.st = defaultMode(p.profile.WidthDots)
p.line = lineBuf{}
p.cmd(s, b[:2], "ESC @ — inicializar: se borran el buffer de línea y todos los modos")
return 2
case 'E':
p.st.bold = b[2]&1 == 1
p.cmd(s, b[:3], "ESC E %d — negrita %s", b[2], onOff(p.st.bold))
return 3
case 'G':
p.st.doubleStrike = b[2]&1 == 1
p.cmd(s, b[:3], "ESC G %d — doble impacto %s", b[2], onOff(p.st.doubleStrike))
return 3
case '-':
switch b[2] {
case 0, '0':
p.st.underline = 0
case 1, '1':
p.st.underline = 1
case 2, '2':
p.st.underline = 2
}
p.cmd(s, b[:3], "ESC - %d — subrayado de %d puntos", b[2], p.st.underline)
return 3
case 'a':
switch b[2] {
case 0, '0':
p.st.align = 0
case 1, '1':
p.st.align = 1
case 2, '2':
p.st.align = 2
}
msg := "ESC a %d — alineación: %s"
if len(p.line.cells) > 0 {
msg += " (a mitad de línea: se aplicará en la siguiente)"
}
p.cmd(s, b[:3], msg, b[2], []string{"izquierda", "centro", "derecha"}[p.st.align])
return 3
case '!':
n := b[2]
p.st.fontB = n&0x01 != 0
p.st.bold = n&0x08 != 0
p.st.heightMul = 1 + int(n>>4&1)
p.st.widthMul = 1 + int(n>>5&1)
p.st.underline = int(n >> 7 & 1)
p.cmd(s, b[:3], "ESC ! 0x%02X — modo: fuente %s, negrita %v, alto×%d, ancho×%d, subrayado %v",
n, map[bool]string{false: "A", true: "B"}[p.st.fontB], p.st.bold, p.st.heightMul, p.st.widthMul, p.st.underline > 0)
return 3
case 'M':
p.st.fontB = b[2] == 1 || b[2] == '1'
p.cmd(s, b[:3], "ESC M %d — fuente %s", b[2], map[bool]string{false: "A (12×24)", true: "B (9×17)"}[p.st.fontB])
return 3
case 'd':
n := int(b[2])
p.cmd(s, b[:3], "ESC d %d — imprime y avanza %d líneas", n, n)
if n == 0 {
p.printLine(0)
} else {
p.printLine(p.st.lineSpacing)
p.paper.grow((n - 1) * p.st.lineSpacing)
}
return 3
case 'J':
p.cmd(s, b[:3], "ESC J %d — imprime y avanza %d puntos (%.1f mm)", b[2], b[2], float64(b[2])/8)
p.printLine(int(b[2]))
return 3
case '2':
p.st.lineSpacing = 30
p.cmd(s, b[:2], "ESC 2 — interlineado por defecto (30 puntos)")
return 2
case '3':
p.st.lineSpacing = int(b[2])
p.cmd(s, b[:3], "ESC 3 %d — interlineado de %d puntos", b[2], b[2])
return 3
case ' ':
p.st.rightSpacing = int(b[2])
p.cmd(s, b[:3], "ESC SP %d — espacio a la derecha de cada carácter: %d puntos", b[2], b[2])
return 3
case 't':
if cp, ok := codePages[b[2]]; ok {
p.st.codePage = b[2]
p.cmd(s, b[:3], "ESC t %d — página de códigos %s", b[2], cp.name)
} else {
p.warnRaw(s, b[:3], "ESC t %d — página de códigos no soportada por el emulador: se mantiene la actual", b[2])
}
return 3
case 'R':
p.st.intl = b[2]
name, ok := intlNames[b[2]]
if !ok {
name = "sin tabla en el emulador: se usa EE.UU."
}
p.cmd(s, b[:3], "ESC R %d — juego de caracteres internacional: %s", b[2], name)
return 3
case 'p':
p.status.DrawerOpen = true
p.sendASB()
p.cmd(s, b[:5], "ESC p %d %d %d — pulso al cajón (pin %d): encendido %d ms, apagado %d ms → CAJÓN ABIERTO",
b[2], b[3], b[4], map[bool]int{false: 2, true: 5}[b[2]&1 == 1], int(b[3])*2, int(b[4])*2)
return 5
case 'i', 'm':
p.cmd(s, b[:2], "ESC %c — corte %s (comando antiguo)", b[1], map[byte]string{'i': "total", 'm': "parcial"}[b[1]])
if b[1] == 'i' {
p.doCut(s, "total", 0)
} else {
p.doCut(s, "parcial", 0)
}
return 2
case '$':
pos := int(b[2]) | int(b[3])<<8
if pos <= p.areaWidth() {
p.line.x = pos
}
p.cmd(s, b[:4], "ESC $ — posición absoluta: punto %d", pos)
return 4
case '\\':
d := int(int16(uint16(b[2]) | uint16(b[3])<<8))
if x := p.line.x + d; x >= 0 && x <= p.areaWidth() {
p.line.x = x
}
p.cmd(s, b[:4], "ESC \\ — posición relativa: %+d puntos", d)
return 4
case '*':
if len(b) < 5 {
return 0
}
m := b[2]
k := int(b[3]) | int(b[4])<<8
bpc := 1
if m == 32 || m == 33 {
bpc = 3
}
total := 5 + k*bpc
if len(b) < total {
return 0
}
p.addBitImage(m, k, b[5:total])
p.cmd(s, b[:total], "ESC * %d — imagen de bits: %d columnas de %d puntos", m, k, 8*bpc)
return total
case 'D':
var tabs []int
for i := 2; i < len(b); i++ {
if b[i] == nul || len(tabs) == 32 {
n := i
if b[i] == nul {
n = i + 1
}
p.st.tabs = tabs
p.cmd(s, b[:n], "ESC D — tabuladores en las columnas %v", tabs)
return n
}
tabs = append(tabs, int(b[i]))
}
return 0
case '&':
if len(b) < 5 {
return 0
}
y, i := int(b[2]), 5
for c := int(b[3]); c <= int(b[4]); c++ {
if len(b) <= i {
return 0
}
i += 1 + y*int(b[i])
if len(b) < i {
return 0
}
}
p.warnRaw(s, b[:i], "ESC & — definir caracteres de usuario: no emulado")
return i
case '(':
if len(b) < 5 {
return 0
}
total := 5 + (int(b[3]) | int(b[4])<<8)
if len(b) < total {
return 0
}
p.warnRaw(s, b[:total], "ESC ( %c — no emulado", b[2])
return total
case '{':
p.st.upsideDown = b[2]&1 == 1
p.warnRaw(s, b[:3], "ESC { %d — impresión boca abajo: no emulada", b[2])
return 3
case 'v':
p.cmd(s, b[:2], "ESC v — enviar estado del sensor de papel")
p.respond(s, p.paperSensorByte())
return 2
case 'u':
p.cmd(s, b[:3], "ESC u %d — enviar estado del periférico (cajón)", b[2])
p.respond(s, p.drawerByte())
return 3
case 'L', 'S', 'T', 'W':
n := map[byte]int{'L': 2, 'S': 2, 'T': 3, 'W': 10}[b[1]]
p.warnRaw(s, b[:n], "ESC %c — comando de modo página: no emulado", b[1])
return n
}
if n, ok := fixed[b[1]]; ok {
p.warnRaw(s, b[:n], "ESC %c — sin efecto en el emulador", b[1])
return n
}
p.warnRaw(s, b[:2], "ESC 0x%02X — comando desconocido", b[1])
return 2
}
func (p *Printer) gsCommand(s *Session, b []byte) int {
if len(b) < 2 {
return 0
}
fixed := gsFixedLen
if n, ok := fixed[b[1]]; ok && len(b) < n {
return 0
}
switch b[1] {
case '!':
n := b[2]
p.st.widthMul = 1 + int(n>>4&7)
p.st.heightMul = 1 + int(n&7)
p.cmd(s, b[:3], "GS ! 0x%02X — tamaño: ancho ×%d, alto ×%d", n, p.st.widthMul, p.st.heightMul)
return 3
case 'B':
p.st.reverse = b[2]&1 == 1
p.cmd(s, b[:3], "GS B %d — blanco sobre negro %s", b[2], onOff(p.st.reverse))
return 3
case 'L':
p.st.leftMargin = int(b[2]) | int(b[3])<<8
p.cmd(s, b[:4], "GS L — margen izquierdo de %d puntos", p.st.leftMargin)
return 4
case 'W':
p.st.areaWidth = int(b[2]) | int(b[3])<<8
p.cmd(s, b[:4], "GS W — ancho del área de impresión: %d puntos", p.st.areaWidth)
return 4
case 'V':
return p.gsCut(s, b)
case 'v':
if len(b) < 3 {
return 0
}
if b[2] != '0' {
p.warnRaw(s, b[:3], "GS v %c — desconocido", b[2])
return 3
}
if len(b) < 8 {
return 0
}
x := int(b[4]) | int(b[5])<<8
y := int(b[6]) | int(b[7])<<8
total := 8 + x*y
if len(b) < total {
return 0
}
p.printRaster(b[3], x, y, b[8:total])
p.cmd(s, b[:total], "GS v 0 %d — imagen raster de %d×%d puntos (%d bytes de datos)", b[3], x*8, y, x*y)
return total
case '(':
if len(b) < 5 {
return 0
}
total := 5 + (int(b[3]) | int(b[4])<<8)
if len(b) < total {
return 0
}
if b[2] == 'k' {
p.gsParenK(s, b[:total])
} else {
p.warnRaw(s, b[:total], "GS ( %c — no emulado", b[2])
}
return total
case '8':
if len(b) < 7 {
return 0
}
n := int64(b[3]) | int64(b[4])<<8 | int64(b[5])<<16 | int64(b[6])<<24
if n > 8<<20 {
p.warnRaw(s, b[:7], "GS 8 %c — longitud absurda (%d): se descarta la cabecera", b[2], n)
return 7
}
total := 7 + int(n)
if len(b) < total {
return 0
}
p.warnRaw(s, b[:total], "GS 8 %c — gráficos extendidos: no emulado", b[2])
return total
case '*':
if len(b) < 4 {
return 0
}
total := 4 + int(b[2])*int(b[3])*8
if len(b) < total {
return 0
}
p.warnRaw(s, b[:total], "GS * — imagen descargable: no emulada")
return total
case 'I':
p.cmd(s, b[:3], "GS I %d — identificación de la impresora", b[2])
p.gsI(s, b[2])
return 3
case 'r':
switch b[2] {
case 1, '1':
p.cmd(s, b[:3], "GS r %d — enviar estado del sensor de papel", b[2])
p.respond(s, p.paperSensorByte())
case 2, '2':
p.cmd(s, b[:3], "GS r %d — enviar estado del cajón", b[2])
p.respond(s, p.drawerByte())
default:
p.warnRaw(s, b[:3], "GS r %d — tipo de estado desconocido", b[2])
}
return 3
case 'a':
p.asbMask = b[2]
p.asbTo = s
if b[2] == 0 {
p.asbTo = nil
}
p.cmd(s, b[:3], "GS a 0x%02X — estado automático (ASB) %s", b[2], onOff(b[2] != 0))
p.sendASB()
return 3
case 'k':
return p.gsBarcode(s, b)
case 'h':
p.st.barcodeHeight = max(1, int(b[2]))
p.cmd(s, b[:3], "GS h %d — altura del código de barras", b[2])
return 3
case 'w':
p.st.barcodeWidth = int(b[2])
p.cmd(s, b[:3], "GS w %d — ancho de módulo del código de barras", b[2])
return 3
case 'H':
p.st.hriPos = b[2]
p.cmd(s, b[:3], "GS H %d — posición del texto legible (HRI)", b[2])
return 3
case 'f':
p.cmd(s, b[:3], "GS f %d — fuente del texto legible (HRI)", b[2])
return 3
}
if n, ok := fixed[b[1]]; ok {
p.warnRaw(s, b[:n], "GS %c — sin efecto en el emulador", b[1])
return n
}
p.warnRaw(s, b[:2], "GS 0x%02X — comando desconocido", b[1])
return 2
}
func (p *Printer) gsCut(s *Session, b []byte) int {
if len(b) < 3 {
return 0
}
m := b[2]
n, feed := 3, 0
kind := ""
switch m {
case 0, '0':
kind = "total"
case 1, '1':
kind = "parcial"
case 65, 66, 97, 98, 103, 104:
if len(b) < 4 {
return 0
}
n = 4
kind = map[bool]string{true: "total", false: "parcial"}[m%2 == 1]
feed = p.profile.CutterOffset + int(b[3])
default:
p.warnRaw(s, b[:3], "GS V %d — modo de corte desconocido", m)
return 3
}
if n == 4 {
p.cmd(s, b[:n], "GS V %d %d — avanza hasta la cuchilla + %d puntos y corte %s", m, b[3], b[3], kind)
} else {
p.cmd(s, b[:n], "GS V %d — corte %s en la posición actual", m, kind)
}
p.doCut(s, kind, feed)
return n
}
func (p *Printer) doCut(s *Session, kind string, feed int) {
if !p.profile.HasCutter {
p.flushLine()
p.paper.grow(feed)
p.warn(s, "Este modelo no tiene cuchilla: el corte se ignora (usa «Arrancar papel»)")
return
}
if p.status.CutterError {
p.warn(s, "Error de cuchilla: no se corta")
return
}
p.cut(s, kind, feed)
}
// gsParenK procesa GS ( k pL pH cn fn [parámetros]: símbolos 2D.
func (p *Printer) gsParenK(s *Session, b []byte) {
if len(b) < 7 {
p.warnRaw(s, b, "GS ( k — demasiado corto")
return
}
cn, fn := b[5], b[6]
if cn != 49 {
p.warnRaw(s, b, "GS ( k cn=%d — símbolo 2D no emulado (solo QR, cn=49)", cn)
return
}
if !p.profile.NativeQR {
p.warnRaw(s, b, "GS ( k — este modelo no soporta QR nativo: comando ignorado")
return
}
switch fn {
case 65:
p.cmd(s, b, "GS ( k QR fn 65 — seleccionar modelo (siempre se usa el modelo 2)")
case 67:
if len(b) >= 8 && b[7] >= 1 && b[7] <= 16 {
p.st.qrModule = b[7]
}
p.cmd(s, b, "GS ( k QR fn 67 — tamaño de módulo: %d puntos", p.st.qrModule)
case 69:
if len(b) >= 8 && b[7] >= 48 && b[7] <= 51 {
p.st.qrECC = b[7]
}
p.cmd(s, b, "GS ( k QR fn 69 — corrección de errores %c", "LMQH"[p.st.qrECC-48])
case 80:
if len(b) >= 8 {
p.st.qrData = append([]byte(nil), b[8:]...)
}
p.cmd(s, b, "GS ( k QR fn 80 — almacenar %d bytes: «%s»", len(p.st.qrData), strings.ToValidUTF8(string(p.st.qrData), "?"))
case 81:
p.cmd(s, b, "GS ( k QR fn 81 — imprimir el QR almacenado")
p.printQR(s)
default:
p.warnRaw(s, b, "GS ( k QR fn %d — no emulado", fn)
}
}
func (p *Printer) gsBarcode(s *Session, b []byte) int {
if len(b) < 3 {
return 0
}
m := b[2]
var data []byte
total := 0
switch {
case m <= 6:
i := bytes.IndexByte(b[3:], nul)
if i < 0 {
if len(b) > 3+255 {
p.warnRaw(s, b[:3], "GS k %d — datos sin NUL final: se descarta", m)
return 3
}
return 0
}
data, total = b[3:3+i], 3+i+1
case m >= 65 && m <= 79:
if len(b) < 4 {
return 0
}
total = 4 + int(b[3])
if len(b) < total {
return 0
}
data = b[4:total]
default:
p.warnRaw(s, b[:3], "GS k %d — tipo de código de barras desconocido", m)
return 3
}
p.warnRaw(s, b[:total], "GS k %d — código de barras %s: se dibuja un marcador (no emulado)", m, barcodeNames[m])
p.printBarcode(m, data)
return total
}
func (p *Printer) gsI(s *Session, n byte) {
pr := p.profile
switch n {
case 1, 49:
p.respond(s, pr.ModelID)
return
case 2, 50:
p.respond(s, pr.TypeID)
return
case 3, 51:
p.respond(s, pr.VersionID)
return
}
info := map[byte]string{65: pr.Firmware, 66: pr.Maker, 67: pr.Model, 68: pr.Serial, 69: "ANK"}[n]
if info == "" || !pr.ExtendedGSI {
p.warn(s, "GS I %d — este modelo no responde (el host se quedará esperando hasta su timeout)", n)
return
}
resp := append([]byte{0x5F}, info...)
p.respond(s, append(resp, nul)...)
}
func (p *Printer) dleCommand(s *Session, b []byte) int {
if len(b) < 2 {
return 0
}
switch b[1] {
case 0x04:
if len(b) < 3 {
return 0
}
if b[2] == 7 || b[2] == 8 {
if len(b) < 4 {
return 0
}
p.warnRaw(s, b[:4], "DLE EOT %d — no emulado", b[2])
return 4
}
p.dleEOT(s, b[:3])
return 3
case 0x05:
if len(b) < 3 {
return 0
}
p.cmd(s, b[:3], "DLE ENQ %d — petición en tiempo real: sin efecto", b[2])
return 3
case 0x14:
if len(b) < 3 {
return 0
}
n := map[byte]int{1: 5, 2: 5, 7: 4, 8: 10}[b[2]]
if n == 0 {
n = 3
}
if len(b) < n {
return 0
}
p.dleDC4(s, b[:n])
return n
}
p.warnRaw(s, b[:1], "DLE suelto: ignorado")
return 1
}
var eotNames = map[byte]string{1: "estado de la impresora", 2: "causa de offline", 3: "causa del error", 4: "sensor de papel"}
func (p *Printer) dleEOT(s *Session, b []byte) {
n := b[2]
st := p.status
r := byte(0x12) // bits 1 y 4 fijos a 1
switch n {
case 1:
if st.DrawerOpen {
r |= 0x04
}
if st.Blocked() {
r |= 0x08
}
case 2:
if st.CoverOpen {
r |= 0x04
}
if st.PaperEnd {
r |= 0x20
}
if st.CoverOpen || st.PaperEnd || st.CutterError {
r |= 0x40
}
case 3:
if st.CutterError {
r |= 0x08
}
case 4:
if st.PaperNearEnd || st.PaperEnd {
r |= 0x0C
}
if st.PaperEnd {
r |= 0x60
}
default:
p.warnRaw(s, b, "DLE EOT %d — n no válido: sin respuesta", n)
return
}
p.cmd(s, b, "DLE EOT %d — tiempo real: %s", n, eotNames[n])
p.respond(s, r)
}
func (p *Printer) dleDC4(s *Session, b []byte) {
switch b[2] {
case 1:
p.status.DrawerOpen = true
p.sendASB()
p.cmd(s, b, "DLE DC4 1 — pulso al cajón en tiempo real → CAJÓN ABIERTO")
case 8:
for _, o := range p.sessions {
o.pending = nil
}
p.line = lineBuf{}
p.cmd(s, b, "DLE DC4 8 — borrar buffers")
default:
p.warnRaw(s, b, "DLE DC4 %d — no emulado", b[2])
}
}
// realtimeWhileBlocked ejecuta DLE EOT y DLE DC4 1 aunque la impresora esté
// detenida (como hace el firmware real) y devuelve el resto de bytes.
func (p *Printer) realtimeWhileBlocked(s *Session, data []byte) []byte {
rest := make([]byte, 0, len(data))
for i := 0; i < len(data); {
if data[i] == dle && i+2 < len(data) {
if data[i+1] == 0x04 && data[i+2] >= 1 && data[i+2] <= 4 {
p.dleEOT(s, data[i:i+3])
i += 3
continue
}
if data[i+1] == 0x14 && data[i+2] == 1 && i+4 < len(data) {
p.dleDC4(s, data[i:i+5])
i += 5
continue
}
}
rest = append(rest, data[i])
i++
}
return rest
}
func (p *Printer) fsCommand(s *Session, b []byte) int {
if len(b) < 2 {
return 0
}
n := map[byte]int{'.': 2, '&': 2, 'C': 3, '!': 3, '-': 3, 'W': 3, 'S': 4, 'p': 4}[b[1]]
if b[1] == '(' {
if len(b) < 5 {
return 0
}
n = 5 + (int(b[3]) | int(b[4])<<8)
}
if n == 0 {
n = 2
}
if len(b) < n {
return 0
}
switch b[1] {
case '.', '&':
p.cmd(s, b[:n], "FS %c — modo kanji %s: sin efecto", b[1], map[byte]string{'.': "desactivado", '&': "activado"}[b[1]])
default:
p.warnRaw(s, b[:n], "FS %c — no emulado", b[1])
}
return n
}
func (p *Printer) paperSensorByte() byte {
var r byte
if p.status.PaperNearEnd || p.status.PaperEnd {
r |= 0x03
}
if p.status.PaperEnd {
r |= 0x0C
}
return r
}
func (p *Printer) drawerByte() byte {
if p.status.DrawerOpen {
return 0x01
}
return 0x00
}
// sendASB envía los 4 bytes de estado automático si el host lo activó con GS a.
func (p *Printer) sendASB() {
if p.asbTo == nil || p.asbMask == 0 {
return
}
st := p.status
b1 := byte(0x10)
if st.DrawerOpen {
b1 |= 0x04
}
if st.Blocked() {
b1 |= 0x08
}
if st.CoverOpen {
b1 |= 0x20
}
var b2 byte
if st.CutterError {
b2 |= 0x08 | 0x40
}
var b3 byte
if st.PaperNearEnd || st.PaperEnd {
b3 |= 0x03
}
if st.PaperEnd {
b3 |= 0x0C
}
p.respond(p.asbTo, b1, b2, b3, 0x00)
}
+538
View File
@@ -0,0 +1,538 @@
// Package escpos emula el comportamiento de una impresora térmica ESC/POS:
// interpreta el flujo de bytes, lo dibuja en un papel virtual y responde a
// los comandos de estado igual que lo haría una impresora real.
package escpos
import (
"fmt"
"image"
"io"
"strings"
"sync"
"time"
)
// Profile describe el modelo de impresora emulado.
type Profile struct {
Key string
Name string
PaperDots int // ancho físico del papel en puntos (8 puntos/mm)
WidthDots int // ancho imprimible en puntos
HasCutter bool // sin cuchilla, GS V se ignora y el papel se arranca a mano
CutterOffset int // distancia en puntos entre el cabezal y la cuchilla
NativeQR bool // soporta GS ( k para QR
ExtendedGSI bool // responde a GS I 65..69 (fabricante, modelo, serie...)
ModelID byte
TypeID byte
VersionID byte
Maker string
Model string
Firmware string
Serial string
USBVendorID uint16
USBProductID uint16
}
var profiles = []Profile{
{
Key: "80mm", Name: "Genérica 80 mm (estilo Epson TM)",
PaperDots: 640, WidthDots: 576, HasCutter: true, CutterOffset: 96,
NativeQR: true, ExtendedGSI: true,
ModelID: 0x20, TypeID: 0x02, VersionID: 0x10,
Maker: "EMULADOR", Model: "ESCPOS-EMU-80", Firmware: "1.00 ESC/POS", Serial: "EMU80-000001",
USBVendorID: 0x1209, USBProductID: 0x0001,
},
{
Key: "58mm", Name: "Genérica 58 mm (clon barato, sin cuchilla)",
PaperDots: 464, WidthDots: 384, HasCutter: false, CutterOffset: 64,
NativeQR: false, ExtendedGSI: false,
ModelID: 0x01, TypeID: 0x00, VersionID: 0x01,
Maker: "EMULADOR", Model: "ESCPOS-EMU-58", Firmware: "0.9", Serial: "EMU58-000001",
USBVendorID: 0x1209, USBProductID: 0x0002,
},
}
// Profiles devuelve los perfiles disponibles.
func Profiles() []Profile { return append([]Profile(nil), profiles...) }
// FindProfile busca un perfil por su clave ("80mm", "58mm").
func FindProfile(key string) (Profile, bool) {
for _, p := range profiles {
if p.Key == key {
return p, true
}
}
return Profile{}, false
}
// Status son las condiciones físicas que se simulan desde la interfaz.
type Status struct {
CoverOpen bool
PaperEnd bool
PaperNearEnd bool
CutterError bool
Offline bool // botón "offline" manual (p.ej. mantenimiento)
DrawerOpen bool // sensor del cajón (pin 3 del conector)
}
// Blocked indica si la impresora ha dejado de imprimir. Los datos que
// lleguen se quedan en el buffer hasta que se resuelva la condición.
func (s Status) Blocked() bool {
return s.CoverOpen || s.PaperEnd || s.CutterError || s.Offline
}
func (s Status) describe() string {
var parts []string
if s.CoverOpen {
parts = append(parts, "tapa abierta")
}
if s.PaperEnd {
parts = append(parts, "sin papel")
} else if s.PaperNearEnd {
parts = append(parts, "papel casi agotado")
}
if s.CutterError {
parts = append(parts, "error de cuchilla")
}
if s.Offline {
parts = append(parts, "offline")
}
if s.DrawerOpen {
parts = append(parts, "cajón abierto")
}
if len(parts) == 0 {
return "normal"
}
return strings.Join(parts, ", ")
}
// Ticket es un trozo de papel ya cortado. Es inmutable una vez creado.
type Ticket struct {
Seq int
Time time.Time
Cut string // "total", "parcial" o "arrancado"
Image *image.Gray
}
// LogKind clasifica las entradas del registro.
type LogKind int
const (
LogCommand LogKind = iota
LogText
LogResponse
LogEvent
LogWarning
)
// LogEntry es una línea del registro: los bytes recibidos y su significado.
type LogEntry struct {
Seq int
Time time.Time
Source string
Kind LogKind
Hex string
Text string
}
// Session representa un canal de entrada (una conexión TCP, el USB...).
// Cada sesión tiene su propio buffer de recepción; el estado de impresión
// (negrita, alineación...) es global a la impresora, como en una real.
type Session struct {
p *Printer
name string
reply func([]byte)
pending []byte
outbox []byte
closed bool
}
// Printer es la impresora emulada. Todos sus métodos son seguros para uso
// concurrente.
type Printer struct {
mu sync.Mutex
profile Profile
status Status
st mode
line lineBuf
paper *paper
tickets []*Ticket
ticketSeq int
newTix []*Ticket
sessions []*Session
logs []LogEntry
logSeq int
capture []byte
asbMask byte
asbTo *Session
fonts [2]*glyphFont
curImg *image.Gray
curImgVer int
onChange func()
onTicket func(*Ticket)
logOut io.Writer
}
const (
maxLogEntries = 3000
maxTickets = 200
maxCapture = 32 << 20
maxPaperDots = 40000 // unos 5 m de papel sin cortar
)
// NewPrinter crea una impresora con el perfil indicado.
func NewPrinter(profile Profile) (*Printer, error) {
fa, fb, err := loadFonts()
if err != nil {
return nil, err
}
p := &Printer{profile: profile, fonts: [2]*glyphFont{fa, fb}}
p.st = defaultMode(profile.WidthDots)
p.paper = newPaper(profile.PaperDots)
p.curImgVer = -1
p.event("Impresora encendida: %s (%d puntos imprimibles)", profile.Name, profile.WidthDots)
return p, nil
}
// SetOnChange registra una función que se llama (fuera del cerrojo) cada
// vez que cambia algo visible.
func (p *Printer) SetOnChange(fn func()) {
p.mu.Lock()
p.onChange = fn
p.mu.Unlock()
}
// SetOnTicket registra una función que recibe cada ticket cortado.
func (p *Printer) SetOnTicket(fn func(*Ticket)) {
p.mu.Lock()
p.onTicket = fn
p.mu.Unlock()
}
// SetLogOutput vuelca además el registro en w (útil en modo sin GUI).
func (p *Printer) SetLogOutput(w io.Writer) {
p.mu.Lock()
p.logOut = w
p.mu.Unlock()
}
// Profile devuelve el perfil activo.
func (p *Printer) Profile() Profile {
p.mu.Lock()
defer p.mu.Unlock()
return p.profile
}
// Status devuelve el estado físico actual.
func (p *Printer) Status() Status {
p.mu.Lock()
defer p.mu.Unlock()
return p.status
}
// NewSession abre un canal de entrada. reply recibe las respuestas que la
// impresora envía al host (DLE EOT, GS I...). Puede ser nil.
func (p *Printer) NewSession(name string, reply func([]byte)) *Session {
p.mu.Lock()
s := &Session{p: p, name: name, reply: reply}
p.sessions = append(p.sessions, s)
p.event("Conexión abierta: %s", name)
p.unlockAndFlush()
return s
}
// Write entrega bytes recibidos por el canal.
func (s *Session) Write(data []byte) {
if len(data) == 0 {
return
}
p := s.p
p.mu.Lock()
if len(p.capture)+len(data) <= maxCapture {
p.capture = append(p.capture, data...)
}
if p.status.Blocked() {
data = p.realtimeWhileBlocked(s, data)
if len(data) > 0 {
p.warn(s, "Impresora detenida (%s): %d bytes guardados en el buffer", p.status.describe(), len(data))
}
}
s.pending = append(s.pending, data...)
p.process(s)
p.unlockAndFlush()
}
// Close cierra el canal. Si quedan datos en el buffer se imprimirán cuando
// la impresora vuelva a estar operativa, igual que en una impresora real.
func (s *Session) Close() {
p := s.p
p.mu.Lock()
s.closed = true
s.reply = nil
if p.asbTo == s {
p.asbTo, p.asbMask = nil, 0
}
p.event("Conexión cerrada: %s", s.name)
p.dropFinishedSessions()
p.unlockAndFlush()
}
// UpdateStatus modifica las condiciones físicas simuladas.
func (p *Printer) UpdateStatus(fn func(*Status)) {
p.mu.Lock()
old := p.status
fn(&p.status)
if old != p.status {
p.event("Estado: %s", p.status.describe())
p.sendASB()
if old.Blocked() && !p.status.Blocked() {
p.resume()
}
}
p.unlockAndFlush()
}
// PowerCycle simula apagar y encender: se pierde el buffer y el modo.
func (p *Printer) PowerCycle() {
p.mu.Lock()
for _, s := range p.sessions {
s.pending = nil
}
p.dropFinishedSessions()
p.st = defaultMode(p.profile.WidthDots)
p.line = lineBuf{}
p.asbTo, p.asbMask = nil, 0
p.event("Impresora reiniciada (apagar/encender): buffer y modos borrados")
p.unlockAndFlush()
}
// TearPaper arranca a mano todo el papel impreso que no se ha cortado.
func (p *Printer) TearPaper() {
p.mu.Lock()
if p.paper.height > 0 {
p.finishTicket(p.paper.height, "arrancado")
p.event("Papel arrancado a mano")
}
p.unlockAndFlush()
}
// ClearTickets borra los tickets ya cortados de la vista.
func (p *Printer) ClearTickets() {
p.mu.Lock()
p.tickets = nil
p.unlockAndFlush()
}
// Tickets devuelve los tickets cortados.
func (p *Printer) Tickets() []*Ticket {
p.mu.Lock()
defer p.mu.Unlock()
return append([]*Ticket(nil), p.tickets...)
}
// Capture devuelve una copia de todos los bytes recibidos.
func (p *Printer) Capture() []byte {
p.mu.Lock()
defer p.mu.Unlock()
return append([]byte(nil), p.capture...)
}
// ClearCapture vacía la captura de bytes.
func (p *Printer) ClearCapture() {
p.mu.Lock()
p.capture = nil
p.unlockAndFlush()
}
// Note añade un mensaje informativo al registro.
func (p *Printer) Note(format string, args ...any) {
p.mu.Lock()
p.event(format, args...)
p.unlockAndFlush()
}
// Snapshot es una foto del estado para pintar la interfaz.
type Snapshot struct {
Profile Profile
Status Status
Tickets []*Ticket
Paper *image.Gray // papel aún dentro de la impresora (puede ser nil)
CutterRow int // fila de Paper donde está la cuchilla (-1 si no se ve)
Log []LogEntry
Connections []string
PendingBytes int
CaptureBytes int
Mode string
}
// Snapshot devuelve el estado actual con como mucho maxLog entradas de registro.
func (p *Printer) Snapshot(maxLog int) Snapshot {
p.mu.Lock()
defer p.mu.Unlock()
snap := Snapshot{
Profile: p.profile,
Status: p.status,
Tickets: append([]*Ticket(nil), p.tickets...),
CaptureBytes: len(p.capture),
Mode: p.st.describe(),
CutterRow: -1,
}
if p.paper.height > 0 {
if p.curImgVer != p.paper.version {
p.curImg = p.paper.image(0, p.paper.height)
p.curImgVer = p.paper.version
}
snap.Paper = p.curImg
if r := p.paper.height - p.profile.CutterOffset; r > 0 {
snap.CutterRow = r
}
}
start := len(p.logs) - maxLog
if start < 0 {
start = 0
}
snap.Log = append([]LogEntry(nil), p.logs[start:]...)
for _, s := range p.sessions {
snap.PendingBytes += len(s.pending)
if !s.closed {
snap.Connections = append(snap.Connections, s.name)
}
}
return snap
}
// process interpreta todo lo que se pueda del buffer de la sesión.
func (p *Printer) process(s *Session) {
for len(s.pending) > 0 && !p.status.Blocked() {
n := p.step(s, s.pending)
if n == 0 {
return // comando incompleto: esperamos más bytes
}
s.pending = s.pending[n:]
}
if len(s.pending) == 0 {
s.pending = nil
}
}
func (p *Printer) resume() {
for _, s := range p.sessions {
if len(s.pending) > 0 {
p.event("Reanudando: %d bytes pendientes de %s", len(s.pending), s.name)
p.process(s)
}
}
p.dropFinishedSessions()
}
func (p *Printer) dropFinishedSessions() {
kept := p.sessions[:0]
for _, s := range p.sessions {
if !s.closed || len(s.pending) > 0 {
kept = append(kept, s)
}
}
for i := len(kept); i < len(p.sessions); i++ {
p.sessions[i] = nil
}
p.sessions = kept
}
// unlockAndFlush libera el cerrojo y después entrega las respuestas y
// avisos pendientes, para no llamar a código externo con el cerrojo cogido.
func (p *Printer) unlockAndFlush() {
type delivery struct {
fn func([]byte)
data []byte
}
var out []delivery
for _, s := range p.sessions {
if len(s.outbox) > 0 && s.reply != nil {
out = append(out, delivery{s.reply, s.outbox})
}
s.outbox = nil
}
tix := p.newTix
p.newTix = nil
onChange, onTicket := p.onChange, p.onTicket
p.mu.Unlock()
for _, d := range out {
d.fn(d.data)
}
if onTicket != nil {
for _, t := range tix {
onTicket(t)
}
}
if onChange != nil {
onChange()
}
}
// respond encola bytes de respuesta hacia el host de la sesión.
func (p *Printer) respond(s *Session, data ...byte) {
if s == nil || s.closed {
return
}
s.outbox = append(s.outbox, data...)
p.addLog(s, LogResponse, data, "→ respuesta al host: "+hexString(data, 16))
}
func (p *Printer) addLog(s *Session, kind LogKind, raw []byte, text string) {
p.logSeq++
src := "—"
if s != nil {
src = s.name
}
e := LogEntry{Seq: p.logSeq, Time: time.Now(), Source: src, Kind: kind, Hex: hexString(raw, 24), Text: text}
p.logs = append(p.logs, e)
if len(p.logs) > maxLogEntries {
p.logs = append([]LogEntry(nil), p.logs[len(p.logs)-maxLogEntries*2/3:]...)
}
if p.logOut != nil {
fmt.Fprintf(p.logOut, "%s %-22s %-40s %s\n", e.Time.Format("15:04:05.000"), e.Source, e.Hex, e.Text)
}
}
func (p *Printer) cmd(s *Session, raw []byte, format string, args ...any) {
p.addLog(s, LogCommand, raw, fmt.Sprintf(format, args...))
}
func (p *Printer) warn(s *Session, format string, args ...any) {
p.addLog(s, LogWarning, nil, fmt.Sprintf(format, args...))
}
func (p *Printer) warnRaw(s *Session, raw []byte, format string, args ...any) {
p.addLog(s, LogWarning, raw, fmt.Sprintf(format, args...))
}
func (p *Printer) event(format string, args ...any) {
p.addLog(nil, LogEvent, nil, fmt.Sprintf(format, args...))
}
func hexString(b []byte, max int) string {
if len(b) == 0 {
return ""
}
var sb strings.Builder
for i, c := range b {
if i == max {
fmt.Fprintf(&sb, "… (%d bytes)", len(b))
break
}
if i > 0 {
sb.WriteByte(' ')
}
fmt.Fprintf(&sb, "%02X", c)
}
return sb.String()
}
func onOff(v bool) string {
if v {
return "activada"
}
return "desactivada"
}
+188
View File
@@ -0,0 +1,188 @@
package escpos
import (
"bytes"
"image/png"
"os"
"path/filepath"
"sync"
"testing"
)
type recorder struct {
mu sync.Mutex
buf []byte
}
func (r *recorder) reply(b []byte) {
r.mu.Lock()
r.buf = append(r.buf, b...)
r.mu.Unlock()
}
func (r *recorder) take() []byte {
r.mu.Lock()
defer r.mu.Unlock()
b := r.buf
r.buf = nil
return b
}
func newTestPrinter(t *testing.T, key string) *Printer {
t.Helper()
prof, ok := FindProfile(key)
if !ok {
t.Fatalf("perfil %q no existe", key)
}
p, err := NewPrinter(prof)
if err != nil {
t.Fatal(err)
}
return p
}
func blackPixels(p *Printer) int {
n := 0
for _, t := range p.Tickets() {
for _, v := range t.Image.Pix {
if v < 0x80 {
n++
}
}
}
return n
}
func TestTextAndCut(t *testing.T) {
p := newTestPrinter(t, "80mm")
s := p.NewSession("test", nil)
s.Write([]byte("\x1b@Hola mundo\n\x1dVA\x00"))
if got := len(p.Tickets()); got != 1 {
t.Fatalf("tickets = %d, want 1", got)
}
if blackPixels(p) == 0 {
t.Fatal("el ticket está en blanco")
}
}
func TestCutWithoutFeedLeavesTail(t *testing.T) {
p := newTestPrinter(t, "80mm")
s := p.NewSession("test", nil)
s.Write([]byte("linea\n\x1dV\x00"))
if got := len(p.Tickets()); got != 0 {
t.Fatalf("con una sola línea bajo la cuchilla no debe salir ticket, salen %d", got)
}
s.Write([]byte("\x1dVA\x00"))
if got := len(p.Tickets()); got != 1 {
t.Fatalf("tickets = %d, want 1", got)
}
}
func TestCommandSplitAcrossWrites(t *testing.T) {
p := newTestPrinter(t, "80mm")
s := p.NewSession("test", nil)
img := []byte{0x1d, 'v', '0', 0, 2, 0, 3, 0, 0xff, 0xff, 0x81, 0x81, 0xff, 0xff}
for _, c := range img {
s.Write([]byte{c})
}
s.Write([]byte("\x1dVA\x00"))
if blackPixels(p) != 16+4+16 {
t.Fatalf("píxeles negros = %d, want 36", blackPixels(p))
}
}
func TestRealtimeStatusWhileCoverOpen(t *testing.T) {
p := newTestPrinter(t, "80mm")
var r recorder
s := p.NewSession("test", r.reply)
s.Write([]byte{dle, 0x04, 1})
if got := r.take(); !bytes.Equal(got, []byte{0x12}) {
t.Fatalf("DLE EOT 1 online = % X, want 12", got)
}
p.UpdateStatus(func(st *Status) { st.CoverOpen = true })
s.Write([]byte("retenido\n"))
s.Write([]byte{dle, 0x04, 2})
if got := r.take(); !bytes.Equal(got, []byte{0x12 | 0x04 | 0x40}) {
t.Fatalf("DLE EOT 2 con tapa abierta = % X, want 56", got)
}
if p.Snapshot(0).PendingBytes == 0 {
t.Fatal("los datos deberían seguir en el buffer")
}
p.UpdateStatus(func(st *Status) { st.CoverOpen = false })
if p.Snapshot(0).PendingBytes != 0 {
t.Fatal("al cerrar la tapa se debería imprimir el buffer")
}
}
func TestGSI(t *testing.T) {
p := newTestPrinter(t, "80mm")
var r recorder
s := p.NewSession("test", r.reply)
s.Write([]byte{gs, 'I', 67})
if got, want := r.take(), []byte("_ESCPOS-EMU-80\x00"); !bytes.Equal(got, want) {
t.Fatalf("GS I 67 = %q, want %q", got, want)
}
p58 := newTestPrinter(t, "58mm")
s58 := p58.NewSession("test", r.reply)
s58.Write([]byte{gs, 'I', 67})
if got := r.take(); len(got) != 0 {
t.Fatalf("el perfil 58mm no debería responder a GS I 67, responde %q", got)
}
}
func TestQRAndDrawer(t *testing.T) {
p := newTestPrinter(t, "80mm")
var r recorder
s := p.NewSession("test", r.reply)
data := "https://example.com"
n := len(data) + 3
cmd := []byte{gs, '(', 'k', byte(n), byte(n >> 8), 49, 80, 48}
cmd = append(cmd, data...)
cmd = append(cmd, gs, '(', 'k', 3, 0, 49, 67, 4)
cmd = append(cmd, gs, '(', 'k', 3, 0, 49, 81, 48)
cmd = append(cmd, esc, 'p', 0, 25, 250)
cmd = append(cmd, dle, 0x04, 1)
cmd = append(cmd, gs, 'V', 'A', 0)
s.Write(cmd)
if blackPixels(p) < 500 {
t.Fatalf("el QR no parece impreso (%d píxeles negros)", blackPixels(p))
}
if got := r.take(); !bytes.Equal(got, []byte{0x16}) {
t.Fatalf("DLE EOT 1 con cajón abierto = % X, want 16", got)
}
}
// TestSampleReceipt genera un ticket de ejemplo. Con ESCPOS_PNG=dir se
// guarda como PNG para inspeccionarlo a ojo.
func TestSampleReceipt(t *testing.T) {
p := newTestPrinter(t, "80mm")
s := p.NewSession("test", nil)
var b bytes.Buffer
b.WriteString("\x1b@\x1bt\x13\x1ba\x01\x1d!\x11TIENDA\n\x1d!\x00")
b.WriteString("\x1bE\x01Negrita\x1bE\x00 normal \x1b-\x01subrayado\x1b-\x00\n")
b.WriteString("\x1ba\x00Ca\xa4a 1,50 \xd5\n") // PC858: ñ y €
b.WriteString("\x1dB\x01 INVERTIDO \x1dB\x00\x1bM\x01 fuente B\x1bM\x00\n")
b.WriteString("\x1ba\x02Total: 12,00\n\x1ba\x00")
b.WriteString("Col1\tCol2\tCol3\n")
b.WriteString("\x1dVB\x10")
s.Write(b.Bytes())
tix := p.Tickets()
if len(tix) != 1 {
t.Fatalf("tickets = %d, want 1", len(tix))
}
if dir := os.Getenv("ESCPOS_PNG"); dir != "" {
f, err := os.Create(filepath.Join(dir, "sample.png"))
if err != nil {
t.Fatal(err)
}
defer f.Close()
if err := png.Encode(f, tix[0].Image); err != nil {
t.Fatal(err)
}
}
}
+597
View File
@@ -0,0 +1,597 @@
package escpos
import (
"fmt"
"image"
"strings"
"time"
"golang.org/x/image/font"
"golang.org/x/image/font/gofont/gomono"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
"golang.org/x/text/encoding/charmap"
"rsc.io/qr"
)
// mode es el estado de impresión que modifican los comandos.
type mode struct {
fontB bool
bold bool
doubleStrike bool
underline int // 0, 1 o 2 puntos
widthMul int // 1..8
heightMul int // 1..8
reverse bool
upsideDown bool
align int // 0 izquierda, 1 centro, 2 derecha
lineSpacing int // puntos
rightSpacing int // puntos entre caracteres
codePage byte
intl byte
leftMargin int
areaWidth int
tabs []int // posiciones en columnas
qrModule byte
qrECC byte
qrData []byte
barcodeHeight int
barcodeWidth int
hriPos byte
}
func defaultMode(width int) mode {
tabs := make([]int, 0, 32)
for i := 8; i <= 256 && len(tabs) < 32; i += 8 {
tabs = append(tabs, i)
}
return mode{
widthMul: 1, heightMul: 1, lineSpacing: 30, areaWidth: width,
tabs: tabs, qrModule: 3, qrECC: 48, barcodeHeight: 162, barcodeWidth: 3,
}
}
func (m mode) describe() string {
parts := []string{"Fuente A"}
if m.fontB {
parts[0] = "Fuente B"
}
parts = append(parts, fmt.Sprintf("tamaño %d×%d", m.widthMul, m.heightMul))
if m.bold || m.doubleStrike {
parts = append(parts, "negrita")
}
if m.underline > 0 {
parts = append(parts, fmt.Sprintf("subrayado %d", m.underline))
}
if m.reverse {
parts = append(parts, "invertido")
}
parts = append(parts, []string{"izquierda", "centro", "derecha"}[m.align])
name := "desconocida"
if cp, ok := codePages[m.codePage]; ok {
name = cp.name
}
parts = append(parts, fmt.Sprintf("página %d (%s)", m.codePage, name))
if m.intl != 0 {
parts = append(parts, fmt.Sprintf("juego internacional %d", m.intl))
}
parts = append(parts, fmt.Sprintf("interlineado %d", m.lineSpacing))
return strings.Join(parts, " · ")
}
type codePage struct {
name string
cm *charmap.Charmap
}
// Números de tabla según ESC t n en impresoras Epson.
var codePages = map[byte]codePage{
0: {"PC437 EE.UU.", charmap.CodePage437},
2: {"PC850 Multilingüe", charmap.CodePage850},
3: {"PC860 Portugués", charmap.CodePage860},
4: {"PC863 Canadá-Francés", charmap.CodePage863},
5: {"PC865 Nórdico", charmap.CodePage865},
16: {"WPC1252", charmap.Windows1252},
17: {"PC866 Cirílico", charmap.CodePage866},
18: {"PC852 Latin 2", charmap.CodePage852},
19: {"PC858 Euro", charmap.CodePage858},
}
// Juegos internacionales (ESC R n): sustituyen algunos bytes ASCII.
var intlSets = map[byte]map[byte]rune{
7: {0x23: '₧', 0x40: '@', 0x5B: '¡', 0x5C: 'Ñ', 0x5D: '¿', 0x7B: '¨', 0x7C: 'ñ', 0x7D: '}'},
11: {0x40: 'á', 0x5B: '¡', 0x5C: 'Ñ', 0x5D: '¿', 0x5E: 'é', 0x60: 'ü', 0x7B: 'í', 0x7C: 'ñ', 0x7D: 'ó', 0x7E: 'ú'},
12: {0x40: 'á', 0x5B: '¡', 0x5C: 'Ñ', 0x5D: '¿', 0x5E: 'é', 0x7B: 'í', 0x7C: 'ñ', 0x7D: 'ó', 0x7E: 'ú'},
}
var intlNames = map[byte]string{0: "EE.UU.", 7: "España I", 11: "Latinoamérica", 12: "España II"}
func (p *Printer) decode(b byte) rune {
if b < 0x80 {
if r, ok := intlSets[p.st.intl][b]; ok {
return r
}
return rune(b)
}
cp, ok := codePages[p.st.codePage]
if !ok {
cp = codePages[0]
}
return cp.cm.DecodeByte(b)
}
// ---------------------------------------------------------------------------
// Papel: mapa de bits monocromo que crece hacia abajo, 1 byte por punto.
type paper struct {
width int
height int
pix []byte
version int
}
func newPaper(width int) *paper { return &paper{width: width} }
func (pp *paper) grow(h int) int {
start := pp.height
if h <= 0 {
return start
}
pp.pix = append(pp.pix, make([]byte, h*pp.width)...)
pp.height += h
pp.version++
return start
}
func (pp *paper) blit(x, y int, bits []byte, w, h int) {
for row := 0; row < h; row++ {
py := y + row
if py < 0 || py >= pp.height {
continue
}
for col := 0; col < w; col++ {
px := x + col
if px < 0 || px >= pp.width || bits[row*w+col] == 0 {
continue
}
pp.pix[py*pp.width+px] = 1
}
}
pp.version++
}
func (pp *paper) image(from, to int) *image.Gray {
img := image.NewGray(image.Rect(0, 0, pp.width, to-from))
src := pp.pix[from*pp.width : to*pp.width]
for i, v := range src {
if v != 0 {
img.Pix[i] = 0x22
} else {
img.Pix[i] = 0xFC
}
}
return img
}
// cut separa las filas [0, at) como ticket y deja el resto en la impresora.
func (pp *paper) cut(at int) *image.Gray {
img := pp.image(0, at)
rest := append([]byte(nil), pp.pix[at*pp.width:]...)
pp.pix = rest
pp.height -= at
pp.version++
return img
}
// ---------------------------------------------------------------------------
// Línea en curso: celdas (caracteres o imágenes de bits) pendientes de imprimir.
type cell struct {
x, w, h int
bits []byte
}
type lineBuf struct {
cells []cell
x int
align int
}
func (p *Printer) areaWidth() int {
w := p.st.areaWidth
if p.st.leftMargin+w > p.profile.WidthDots {
w = p.profile.WidthDots - p.st.leftMargin
}
if w < 0 {
w = 0
}
return w
}
func (p *Printer) sideMargin() int { return (p.profile.PaperDots - p.profile.WidthDots) / 2 }
func (p *Printer) alignOffset(align, used int) int {
free := p.areaWidth() - used
if free <= 0 {
return 0
}
switch align {
case 1:
return free / 2
case 2:
return free
}
return 0
}
func (p *Printer) placeCell(c cell, wrap bool) {
area := p.areaWidth()
if p.line.x+c.w > area && wrap && len(p.line.cells) > 0 {
p.printLine(p.st.lineSpacing)
}
if p.line.x+c.w > area {
c = c.cropWidth(area - p.line.x)
if c.w <= 0 {
return
}
}
if len(p.line.cells) == 0 {
// ESC a solo tiene efecto si se procesa al principio de la línea.
p.line.align = p.st.align
}
c.x = p.line.x
p.line.cells = append(p.line.cells, c)
p.line.x += c.w
}
func (c cell) cropWidth(w int) cell {
if w >= c.w {
return c
}
if w <= 0 {
return cell{}
}
bits := make([]byte, w*c.h)
for y := 0; y < c.h; y++ {
copy(bits[y*w:(y+1)*w], c.bits[y*c.w:y*c.w+w])
}
return cell{w: w, h: c.h, bits: bits}
}
func (p *Printer) lineHeight() int {
h := 0
for _, c := range p.line.cells {
if c.h > h {
h = c.h
}
}
return h
}
// printLine imprime la línea en curso y avanza como mínimo feed puntos.
func (p *Printer) printLine(feed int) {
lineH := p.lineHeight()
block := max(lineH, feed)
start := p.paper.grow(block)
if len(p.line.cells) > 0 {
x0 := p.sideMargin() + p.st.leftMargin + p.alignOffset(p.line.align, p.line.x)
for _, c := range p.line.cells {
p.paper.blit(x0+c.x, start+lineH-c.h, c.bits, c.w, c.h)
}
}
p.line = lineBuf{}
p.checkPaperLength()
}
func (p *Printer) flushLine() {
if len(p.line.cells) > 0 {
p.printLine(p.st.lineSpacing)
}
p.line = lineBuf{}
}
// printBlock imprime un bloque gráfico (raster, QR...) respetando ESC a.
func (p *Printer) printBlock(bits []byte, w, h int) {
c := cell{w: w, h: h, bits: bits}.cropWidth(p.areaWidth())
start := p.paper.grow(c.h)
x0 := p.sideMargin() + p.st.leftMargin + p.alignOffset(p.st.align, c.w)
p.paper.blit(x0, start, c.bits, c.w, c.h)
p.checkPaperLength()
}
func (p *Printer) checkPaperLength() {
if p.paper.height > maxPaperDots {
p.finishTicket(p.paper.height, "arrancado")
p.event("Más de %d mm sin cortar: el papel se arranca solo", maxPaperDots/8)
}
}
func (p *Printer) finishTicket(at int, kind string) {
p.ticketSeq++
t := &Ticket{Seq: p.ticketSeq, Time: time.Now(), Cut: kind, Image: p.paper.cut(at)}
p.tickets = append(p.tickets, t)
if len(p.tickets) > maxTickets {
p.tickets = append([]*Ticket(nil), p.tickets[len(p.tickets)-maxTickets:]...)
}
p.newTix = append(p.newTix, t)
}
// cut imprime lo pendiente, avanza feed puntos y corta por la cuchilla.
func (p *Printer) cut(s *Session, kind string, feed int) {
p.flushLine()
p.paper.grow(feed)
at := p.paper.height - p.profile.CutterOffset
if at <= 0 {
p.warn(s, "Corte sin papel impreso por encima de la cuchilla: no sale ningún ticket")
return
}
if p.profile.CutterOffset > 0 && feed < p.profile.CutterOffset {
p.warn(s, "La cuchilla está %d mm por encima del cabezal: las últimas %d filas se quedan para el siguiente ticket (usa GS V 65 n / 66 n para avanzar antes de cortar)",
p.profile.CutterOffset/8, p.profile.CutterOffset)
}
p.finishTicket(at, kind)
}
// ---------------------------------------------------------------------------
// Caracteres
type glyphFont struct {
w, h int
face font.Face
baseline int
cache map[rune][]byte
}
func loadFonts() (*glyphFont, *glyphFont, error) {
f, err := opentype.Parse(gomono.TTF)
if err != nil {
return nil, nil, err
}
// Go Mono avanza 0,6 em: 20 px -> 12 puntos (fuente A 12×24), 15 px -> 9 (fuente B 9×17).
a, err := newGlyphFont(f, 20, 12, 24)
if err != nil {
return nil, nil, err
}
b, err := newGlyphFont(f, 15, 9, 17)
if err != nil {
return nil, nil, err
}
return a, b, nil
}
func newGlyphFont(f *opentype.Font, size float64, w, h int) (*glyphFont, error) {
face, err := opentype.NewFace(f, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull})
if err != nil {
return nil, err
}
m := face.Metrics()
asc, desc := m.Ascent.Ceil(), m.Descent.Ceil()
return &glyphFont{w: w, h: h, face: face, baseline: asc + (h-(asc+desc))/2, cache: map[rune][]byte{}}, nil
}
func (g *glyphFont) glyph(r rune) []byte {
if bits, ok := g.cache[r]; ok {
return bits
}
img := image.NewAlpha(image.Rect(0, 0, g.w, g.h))
x := fixed.I(0)
if adv, ok := g.face.GlyphAdvance(r); ok {
x = (fixed.I(g.w) - adv) / 2
}
d := font.Drawer{Dst: img, Src: image.Opaque, Face: g.face, Dot: fixed.Point26_6{X: x, Y: fixed.I(g.baseline)}}
d.DrawString(string(r))
bits := make([]byte, g.w*g.h)
for i, a := range img.Pix {
if a >= 0x80 {
bits[i] = 1
}
}
g.cache[r] = bits
return bits
}
func (p *Printer) currentFont() *glyphFont {
if p.st.fontB {
return p.fonts[1]
}
return p.fonts[0]
}
// charCell dibuja un carácter con los atributos actuales, como haría el
// firmware: glifo de mapa de bits ampliado punto a punto.
func (p *Printer) charCell(r rune) cell {
f := p.currentFont()
g := f.glyph(r)
mw, mh := p.st.widthMul, p.st.heightMul
bold := p.st.bold || p.st.doubleStrike
w := (f.w + p.st.rightSpacing) * mw
h := f.h * mh
bits := make([]byte, w*h)
for y := 0; y < f.h; y++ {
for x := 0; x < f.w; x++ {
on := g[y*f.w+x] != 0 || (bold && x > 0 && g[y*f.w+x-1] != 0)
if !on {
continue
}
for dy := 0; dy < mh; dy++ {
row := (y*mh + dy) * w
for dx := 0; dx < mw; dx++ {
bits[row+x*mw+dx] = 1
}
}
}
}
if p.st.underline > 0 && !p.st.reverse {
for t := 0; t < p.st.underline; t++ {
row := (h - 1 - t) * w
for x := 0; x < w; x++ {
bits[row+x] = 1
}
}
}
if p.st.reverse {
for i := range bits {
bits[i] ^= 1
}
}
return cell{w: w, h: h, bits: bits}
}
func (p *Printer) addChar(r rune) { p.placeCell(p.charCell(r), true) }
func (p *Printer) tab() {
adv := p.currentFont().w + p.st.rightSpacing
for _, t := range p.st.tabs {
if pos := t * adv; pos > p.line.x {
if pos <= p.areaWidth() {
p.line.x = pos
}
return
}
}
}
// textBits dibuja un texto corto con la fuente B (para rótulos del emulador).
func (p *Printer) textBits(s string) ([]byte, int, int) {
f := p.fonts[1]
runes := []rune(s)
w, h := len(runes)*f.w, f.h
bits := make([]byte, w*h)
for i, r := range runes {
g := f.glyph(r)
for y := 0; y < f.h; y++ {
copy(bits[y*w+i*f.w:y*w+(i+1)*f.w], g[y*f.w:(y+1)*f.w])
}
}
return bits, w, h
}
// ---------------------------------------------------------------------------
// Imágenes
// addBitImage procesa ESC * (imagen por columnas, se imprime con la línea).
func (p *Printer) addBitImage(m byte, k int, data []byte) {
hs, vs, bpc := 1, 1, 3
switch m {
case 0:
hs, vs, bpc = 2, 3, 1
case 1:
hs, vs, bpc = 1, 3, 1
case 32:
hs, vs, bpc = 2, 1, 3
}
w, h := k*hs, 8*bpc*vs
bits := make([]byte, w*h)
for col := 0; col < k; col++ {
for bi := 0; bi < bpc; bi++ {
v := data[col*bpc+bi]
for bit := 0; bit < 8; bit++ {
if v&(0x80>>bit) == 0 {
continue
}
y0, x0 := (bi*8+bit)*vs, col*hs
for dy := 0; dy < vs; dy++ {
for dx := 0; dx < hs; dx++ {
bits[(y0+dy)*w+x0+dx] = 1
}
}
}
}
}
p.placeCell(cell{w: w, h: h, bits: bits}, false)
}
// printRaster procesa GS v 0 (imagen por filas, 1 bit por punto, MSB a la izquierda).
func (p *Printer) printRaster(m byte, xBytes, rows int, data []byte) {
sx, sy := 1, 1
if m&1 != 0 {
sx = 2
}
if m&2 != 0 {
sy = 2
}
w, h := xBytes*8*sx, rows*sy
bits := make([]byte, w*h)
for y := 0; y < rows; y++ {
for xb := 0; xb < xBytes; xb++ {
v := data[y*xBytes+xb]
for bit := 0; bit < 8; bit++ {
if v&(0x80>>bit) == 0 {
continue
}
x0 := (xb*8 + bit) * sx
for dy := 0; dy < sy; dy++ {
for dx := 0; dx < sx; dx++ {
bits[(y*sy+dy)*w+x0+dx] = 1
}
}
}
}
}
p.flushLine()
p.printBlock(bits, w, h)
}
func (p *Printer) printQR(s *Session) {
if len(p.st.qrData) == 0 {
p.warn(s, "GS ( k 81: no hay datos QR almacenados (falta la función 80)")
return
}
level := map[byte]qr.Level{48: qr.L, 49: qr.M, 50: qr.Q, 51: qr.H}[p.st.qrECC]
code, err := qr.Encode(string(p.st.qrData), level)
if err != nil {
p.warn(s, "QR no generado: %v", err)
return
}
ms := int(p.st.qrModule)
w := code.Size * ms
bits := make([]byte, w*w)
for y := 0; y < code.Size; y++ {
for x := 0; x < code.Size; x++ {
if !code.Black(x, y) {
continue
}
for dy := 0; dy < ms; dy++ {
for dx := 0; dx < ms; dx++ {
bits[(y*ms+dy)*w+x*ms+dx] = 1
}
}
}
}
p.flushLine()
p.printBlock(bits, w, w)
}
var barcodeNames = map[byte]string{
0: "UPC-A", 1: "UPC-E", 2: "EAN13", 3: "EAN8", 4: "CODE39", 5: "ITF", 6: "CODABAR",
65: "UPC-A", 66: "UPC-E", 67: "EAN13", 68: "EAN8", 69: "CODE39", 70: "ITF", 71: "CODABAR",
72: "CODE93", 73: "CODE128",
}
// printBarcode dibuja un marcador: los códigos de barras no se emulan.
func (p *Printer) printBarcode(m byte, data []byte) {
name := barcodeNames[m]
if name == "" {
name = fmt.Sprintf("tipo %d", m)
}
label, lw, lh := p.textBits(fmt.Sprintf("[%s] %s", name, strings.ToValidUTF8(string(data), "?")))
w := min(max(lw+16, 200), p.areaWidth())
h := max(p.st.barcodeHeight, lh+8)
bits := make([]byte, w*h)
for x := 0; x < w; x++ {
bits[x], bits[(h-1)*w+x] = 1, 1
}
for y := 0; y < h; y++ {
bits[y*w], bits[y*w+w-1] = 1, 1
}
oy, ox := (h-lh)/2, 8
for y := 0; y < lh; y++ {
for x := 0; x < lw && ox+x < w-1; x++ {
bits[(oy+y)*w+ox+x] = label[y*lw+x]
}
}
p.flushLine()
p.printBlock(bits, w, h)
}
+477
View File
@@ -0,0 +1,477 @@
// Package gui muestra la impresora emulada: el papel que sale, los
// interruptores de estado físico y el registro de bytes recibidos.
package gui
import (
"fmt"
"image"
"image/color"
"os"
"gioui.org/app"
"gioui.org/font"
"gioui.org/font/gofont"
"gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/text"
"gioui.org/unit"
"gioui.org/widget"
"gioui.org/widget/material"
"escpos-emulator/internal/escpos"
)
type (
C = layout.Context
D = layout.Dimensions
)
// Info describe por dónde se expone la impresora.
type Info struct {
LAN string
USBIP string
BusID string
}
var (
colSide = rgb(0xf3f4f6)
colDesk = rgb(0x3a3f47)
colLogBg = rgb(0xffffff)
colMuted = rgb(0x6b7280)
colDanger = rgb(0xc62828)
colOK = rgb(0x2e7d32)
colText = rgb(0x1b5e20)
colReply = rgb(0x1565c0)
colWarn = rgb(0xb45309)
colEvent = rgb(0x6b7280)
colCmd = rgb(0x111827)
colCutMark = rgb(0xaab0b8)
colHead = rgb(0x22262b)
)
var zoomLevels = []float32{0.5, 0.75, 1, 1.25, 1.5, 2}
type ui struct {
p *escpos.Printer
info Info
w *app.Window
th *material.Theme
coverOpen, paperEnd, nearEnd, cutterErr, offline widget.Bool
closeDrawer, tear, powerCycle, clearTickets, savePNG widget.Clickable
saveCapture, clearCapture, zoomIn, zoomOut widget.Clickable
side, paperList, logList widget.List
ticketOps map[*escpos.Ticket]paint.ImageOp
curImg *image.Gray
curOp paint.ImageOp
zoom int
message string
}
// Run abre la ventana y no retorna: al cerrarla termina el proceso.
func Run(p *escpos.Printer, info Info) {
th := material.NewTheme()
th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
th.TextSize = 14
u := &ui{p: p, info: info, th: th, ticketOps: map[*escpos.Ticket]paint.ImageOp{}, zoom: 2}
u.side.Axis = layout.Vertical
u.paperList.Axis = layout.Vertical
u.paperList.ScrollToEnd = true
u.logList.Axis = layout.Vertical
u.logList.ScrollToEnd = true
u.w = new(app.Window)
u.w.Option(app.Title("Emulador ESC/POS — "+p.Profile().Name), app.Size(unit.Dp(1400), unit.Dp(900)))
p.SetOnChange(u.w.Invalidate)
go func() {
var ops op.Ops
for {
switch e := u.w.Event().(type) {
case app.DestroyEvent:
if e.Err != nil {
fmt.Fprintln(os.Stderr, e.Err)
os.Exit(1)
}
os.Exit(0)
case app.FrameEvent:
gtx := app.NewContext(&ops, e)
u.layout(gtx)
e.Frame(gtx.Ops)
}
}
}()
app.Main()
}
func (u *ui) handleEvents(gtx C) {
toggle := func(b *widget.Bool, set func(*escpos.Status, bool)) {
if b.Update(gtx) {
v := b.Value
u.p.UpdateStatus(func(s *escpos.Status) { set(s, v) })
}
}
toggle(&u.coverOpen, func(s *escpos.Status, v bool) { s.CoverOpen = v })
toggle(&u.paperEnd, func(s *escpos.Status, v bool) { s.PaperEnd = v })
toggle(&u.nearEnd, func(s *escpos.Status, v bool) { s.PaperNearEnd = v })
toggle(&u.cutterErr, func(s *escpos.Status, v bool) { s.CutterError = v })
toggle(&u.offline, func(s *escpos.Status, v bool) { s.Offline = v })
if u.closeDrawer.Clicked(gtx) {
u.p.UpdateStatus(func(s *escpos.Status) { s.DrawerOpen = false })
}
if u.tear.Clicked(gtx) {
u.p.TearPaper()
}
if u.powerCycle.Clicked(gtx) {
u.p.PowerCycle()
}
if u.clearTickets.Clicked(gtx) {
u.p.ClearTickets()
u.ticketOps = map[*escpos.Ticket]paint.ImageOp{}
}
if u.savePNG.Clicked(gtx) {
tix := u.p.Tickets()
var last string
for _, t := range tix {
path, err := escpos.SaveTicketPNG("tickets", t)
if err != nil {
u.message = "Error: " + err.Error()
return
}
last = path
}
if len(tix) == 0 {
u.message = "No hay tickets cortados que guardar"
} else {
u.message = fmt.Sprintf("%d tickets guardados (último: %s)", len(tix), last)
}
}
if u.saveCapture.Clicked(gtx) {
path, n, err := u.p.SaveCapture("capturas")
if err != nil {
u.message = "Error: " + err.Error()
} else {
u.message = fmt.Sprintf("%d bytes guardados en %s", n, path)
}
}
if u.clearCapture.Clicked(gtx) {
u.p.ClearCapture()
u.message = "Captura vaciada"
}
if u.zoomIn.Clicked(gtx) && u.zoom < len(zoomLevels)-1 {
u.zoom++
}
if u.zoomOut.Clicked(gtx) && u.zoom > 0 {
u.zoom--
}
}
func (u *ui) layout(gtx C) D {
u.handleEvents(gtx)
snap := u.p.Snapshot(600)
u.coverOpen.Value = snap.Status.CoverOpen
u.paperEnd.Value = snap.Status.PaperEnd
u.nearEnd.Value = snap.Status.PaperNearEnd
u.cutterErr.Value = snap.Status.CutterError
u.offline.Value = snap.Status.Offline
if len(u.ticketOps) > len(snap.Tickets) {
u.ticketOps = map[*escpos.Ticket]paint.ImageOp{}
}
return layout.Flex{}.Layout(gtx,
layout.Rigid(func(gtx C) D {
gtx.Constraints.Min.X = gtx.Dp(320)
gtx.Constraints.Max.X = gtx.Constraints.Min.X
return pane(gtx, colSide, func(gtx C) D { return u.sidebar(gtx, snap) })
}),
layout.Flexed(0.55, func(gtx C) D {
return pane(gtx, colDesk, func(gtx C) D { return u.paperView(gtx, snap) })
}),
layout.Flexed(0.45, func(gtx C) D {
return pane(gtx, colLogBg, func(gtx C) D { return u.logView(gtx, snap) })
}),
)
}
// ---------------------------------------------------------------------------
// Panel lateral
func (u *ui) sidebar(gtx C, snap escpos.Snapshot) D {
prof := snap.Profile
st := snap.Status
var items []layout.Widget
add := func(w ...layout.Widget) { items = append(items, w...) }
add(u.label(unit.Sp(20), "Emulador ESC/POS", colCmd, font.Bold))
add(u.label(unit.Sp(13), prof.Name, colMuted, font.Normal))
cutter := "sin cuchilla"
if prof.HasCutter {
cutter = fmt.Sprintf("cuchilla a %d mm del cabezal", prof.CutterOffset/8)
}
add(u.label(unit.Sp(13), fmt.Sprintf("Papel %d mm · %d puntos imprimibles · %s", prof.PaperDots/8, prof.WidthDots, cutter), colMuted, font.Normal))
add(u.section("Conexiones"))
if u.info.LAN != "" {
add(u.label(unit.Sp(13), "LAN (TCP RAW): "+u.info.LAN, colCmd, font.Normal))
}
if u.info.USBIP != "" {
add(u.label(unit.Sp(13), fmt.Sprintf("USB/IP: %s · busid %s · %04X:%04X", u.info.USBIP, u.info.BusID, prof.USBVendorID, prof.USBProductID), colCmd, font.Normal))
}
if len(snap.Connections) == 0 {
add(u.label(unit.Sp(13), "Ninguna conexión activa", colMuted, font.Normal))
}
for _, c := range snap.Connections {
add(u.label(unit.Sp(13), "● "+c, colOK, font.Normal))
}
add(u.section("Estado físico"))
add(u.check(&u.coverOpen, "Tapa abierta"))
add(u.check(&u.paperEnd, "Sin papel"))
add(u.check(&u.nearEnd, "Papel casi agotado"))
add(u.check(&u.cutterErr, "Error de cuchilla"))
add(u.check(&u.offline, "Offline (pausa manual)"))
if st.DrawerOpen {
add(u.label(unit.Sp(14), "Cajón: ABIERTO", colDanger, font.Bold))
add(u.buttons(btn{&u.closeDrawer, "Cerrar cajón"}))
} else {
add(u.label(unit.Sp(14), "Cajón: cerrado", colMuted, font.Normal))
}
if st.Blocked() {
add(u.label(unit.Sp(14), fmt.Sprintf("IMPRESORA DETENIDA · %d bytes en el buffer", snap.PendingBytes), colDanger, font.Bold))
} else if snap.PendingBytes > 0 {
add(u.label(unit.Sp(13), fmt.Sprintf("Esperando el resto de un comando: %d bytes", snap.PendingBytes), colWarn, font.Normal))
}
add(u.section("Modo de impresión actual"))
add(u.label(unit.Sp(13), snap.Mode, colCmd, font.Normal))
add(u.section("Papel"))
add(u.buttons(btn{&u.tear, "Arrancar papel"}, btn{&u.clearTickets, "Borrar tickets"}))
add(u.buttons(btn{&u.savePNG, "Guardar PNG"}, btn{&u.zoomOut, "−"}, btn{&u.zoomIn, "+"}))
add(u.label(unit.Sp(12), fmt.Sprintf("Zoom %.0f%% · %d tickets cortados", zoomLevels[u.zoom]*100, len(snap.Tickets)), colMuted, font.Normal))
add(u.section("Captura de bytes"))
add(u.label(unit.Sp(13), fmt.Sprintf("%d bytes recibidos desde el último vaciado", snap.CaptureBytes), colCmd, font.Normal))
add(u.buttons(btn{&u.saveCapture, "Guardar .bin"}, btn{&u.clearCapture, "Vaciar"}))
add(u.section("Alimentación"))
add(u.buttons(btn{&u.powerCycle, "Apagar y encender"}))
if u.message != "" {
add(u.section("Última acción"))
add(u.label(unit.Sp(12), u.message, colMuted, font.Normal))
}
return material.List(u.th, &u.side).Layout(gtx, len(items), func(gtx C, i int) D {
return layout.Inset{Left: unit.Dp(14), Right: unit.Dp(14), Top: unit.Dp(3), Bottom: unit.Dp(3)}.Layout(gtx, items[i])
})
}
func (u *ui) label(size unit.Sp, txt string, c color.NRGBA, weight font.Weight) layout.Widget {
return func(gtx C) D {
l := material.Label(u.th, size, txt)
l.Color = c
l.Font.Weight = weight
return l.Layout(gtx)
}
}
func (u *ui) section(title string) layout.Widget {
return func(gtx C) D {
return layout.Inset{Top: unit.Dp(12)}.Layout(gtx, u.label(unit.Sp(12), title, colMuted, font.Bold))
}
}
func (u *ui) check(b *widget.Bool, txt string) layout.Widget {
return func(gtx C) D {
cb := material.CheckBox(u.th, b, txt)
cb.TextSize = unit.Sp(14)
cb.Size = unit.Dp(20)
if b.Value {
cb.Color = colDanger
cb.IconColor = colDanger
}
return cb.Layout(gtx)
}
}
type btn struct {
c *widget.Clickable
label string
}
func (u *ui) buttons(bs ...btn) layout.Widget {
return func(gtx C) D {
children := make([]layout.FlexChild, 0, len(bs))
for _, b := range bs {
children = append(children, layout.Rigid(func(gtx C) D {
return layout.Inset{Right: unit.Dp(6), Top: unit.Dp(2)}.Layout(gtx, func(gtx C) D {
bt := material.Button(u.th, b.c, b.label)
bt.TextSize = unit.Sp(13)
bt.Inset = layout.Inset{Top: unit.Dp(6), Bottom: unit.Dp(6), Left: unit.Dp(10), Right: unit.Dp(10)}
return bt.Layout(gtx)
})
}))
}
return layout.Flex{}.Layout(gtx, children...)
}
}
// ---------------------------------------------------------------------------
// Papel
func (u *ui) scale(gtx C) float32 { return zoomLevels[u.zoom] / gtx.Metric.PxPerDp }
func (u *ui) paperView(gtx C, snap escpos.Snapshot) D {
n := len(snap.Tickets) + 1
return material.List(u.th, &u.paperList).Layout(gtx, n, func(gtx C, i int) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
if i < len(snap.Tickets) {
return u.ticketItem(gtx, snap.Tickets[i])
}
return u.currentPaper(gtx, snap)
})
}
func (u *ui) ticketItem(gtx C, t *escpos.Ticket) D {
imgOp, ok := u.ticketOps[t]
if !ok {
imgOp = paint.NewImageOp(t.Image)
imgOp.Filter = paint.FilterNearest
u.ticketOps[t] = imgOp
}
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Inset{Top: unit.Dp(16)}.Layout(gtx, func(gtx C) D {
return center(gtx, widget.Image{Src: imgOp, Scale: u.scale(gtx)}.Layout)
})
}),
layout.Rigid(func(gtx C) D {
txt := fmt.Sprintf("✂ ─ ─ ─ corte %s · ticket #%d · %s ─ ─ ─", t.Cut, t.Seq, t.Time.Format("15:04:05"))
return center(gtx, u.label(unit.Sp(12), txt, colCutMark, font.Normal))
}),
)
}
func (u *ui) currentPaper(gtx C, snap escpos.Snapshot) D {
paperW := gtx.Dp(unit.Dp(float32(snap.Profile.PaperDots) * u.scale(gtx)))
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Inset{Top: unit.Dp(24)}.Layout(gtx, func(gtx C) D {
return center(gtx, u.label(unit.Sp(12), "Papel dentro de la impresora (aún sin cortar)", colCutMark, font.Normal))
})
}),
layout.Rigid(func(gtx C) D {
if snap.Paper == nil {
return D{}
}
if snap.Paper != u.curImg {
u.curImg = snap.Paper
u.curOp = paint.NewImageOp(snap.Paper)
u.curOp.Filter = paint.FilterNearest
}
return center(gtx, func(gtx C) D {
dims := widget.Image{Src: u.curOp, Scale: u.scale(gtx)}.Layout(gtx)
if snap.CutterRow > 0 {
y := snap.CutterRow * dims.Size.Y / snap.Paper.Bounds().Dy()
st := op.Offset(image.Pt(0, y)).Push(gtx.Ops)
paint.FillShape(gtx.Ops, colDanger, clip.Rect{Max: image.Pt(dims.Size.X, max(1, gtx.Dp(1)))}.Op())
st.Pop()
}
return dims
})
}),
layout.Rigid(func(gtx C) D {
return center(gtx, func(gtx C) D {
sz := image.Pt(paperW+gtx.Dp(40), gtx.Dp(34))
paint.FillShape(gtx.Ops, colHead, clip.UniformRRect(image.Rectangle{Max: sz}, gtx.Dp(4)).Op(gtx.Ops))
st := op.Offset(image.Pt(gtx.Dp(12), gtx.Dp(8))).Push(gtx.Ops)
msg := "▲ cabezal térmico"
if snap.Profile.HasCutter {
msg += " · la línea roja marca la cuchilla"
}
u.label(unit.Sp(12), msg, rgb(0xd1d5db), font.Normal)(gtx)
st.Pop()
return D{Size: sz}
})
}),
layout.Rigid(layout.Spacer{Height: unit.Dp(24)}.Layout),
)
}
// ---------------------------------------------------------------------------
// Registro
func kindColor(k escpos.LogKind) color.NRGBA {
switch k {
case escpos.LogText:
return colText
case escpos.LogResponse:
return colReply
case escpos.LogWarning:
return colWarn
case escpos.LogEvent:
return colEvent
}
return colCmd
}
func (u *ui) logView(gtx C, snap escpos.Snapshot) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
return layout.Inset{Left: unit.Dp(10), Top: unit.Dp(10), Bottom: unit.Dp(6)}.Layout(gtx,
u.label(unit.Sp(14), "Registro: bytes recibidos y su significado", colCmd, font.Bold))
}),
layout.Flexed(1, func(gtx C) D {
return material.List(u.th, &u.logList).Layout(gtx, len(snap.Log), func(gtx C, i int) D {
e := snap.Log[i]
return layout.Inset{Left: unit.Dp(10), Right: unit.Dp(10), Top: unit.Dp(3), Bottom: unit.Dp(3)}.Layout(gtx, func(gtx C) D {
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
layout.Rigid(func(gtx C) D {
head := fmt.Sprintf("%s [%s] %s", e.Time.Format("15:04:05.000"), e.Source, e.Text)
return u.label(unit.Sp(13), head, kindColor(e.Kind), font.Normal)(gtx)
}),
layout.Rigid(func(gtx C) D {
if e.Hex == "" {
return D{}
}
l := material.Label(u.th, unit.Sp(12), e.Hex)
l.Font.Typeface = "Go Mono"
l.Color = colMuted
return l.Layout(gtx)
}),
)
})
})
}),
)
}
// ---------------------------------------------------------------------------
func pane(gtx C, c color.NRGBA, w layout.Widget) D {
size := gtx.Constraints.Max
paint.FillShape(gtx.Ops, c, clip.Rect{Max: size}.Op())
gtx.Constraints.Min = size
w(gtx)
return D{Size: size}
}
func rgb(v uint32) color.NRGBA {
return color.NRGBA{R: uint8(v >> 16), G: uint8(v >> 8), B: uint8(v), A: 0xff}
}
// center centra w horizontalmente en todo el ancho disponible.
func center(gtx C, w layout.Widget) D {
gtx.Constraints.Min.X = gtx.Constraints.Max.X
return layout.Center.Layout(gtx, w)
}
+65
View File
@@ -0,0 +1,65 @@
// Package lan expone la impresora por TCP en modo RAW (puerto 9100,
// también llamado JetDirect / AppSocket), como las impresoras de red reales.
package lan
import (
"errors"
"net"
"escpos-emulator/internal/escpos"
)
// Server acepta conexiones TCP y entrega los bytes a la impresora.
type Server struct {
ln net.Listener
printer *escpos.Printer
}
// Listen abre el puerto y empieza a aceptar conexiones en segundo plano.
func Listen(addr string, p *escpos.Printer) (*Server, error) {
ln, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
s := &Server{ln: ln, printer: p}
go s.serve()
return s, nil
}
// Addr devuelve la dirección en la que escucha.
func (s *Server) Addr() net.Addr { return s.ln.Addr() }
// Close deja de aceptar conexiones.
func (s *Server) Close() error { return s.ln.Close() }
func (s *Server) serve() {
for {
conn, err := s.ln.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return
}
continue
}
go s.handle(conn)
}
}
func (s *Server) handle(conn net.Conn) {
defer conn.Close()
session := s.printer.NewSession("LAN "+conn.RemoteAddr().String(), func(b []byte) {
_, _ = conn.Write(b)
})
defer session.Close()
buf := make([]byte, 4096)
for {
n, err := conn.Read(buf)
if n > 0 {
session.Write(buf[:n])
}
if err != nil {
return
}
}
}
+485
View File
@@ -0,0 +1,485 @@
// Package usbip exporta la impresora como un dispositivo USB real mediante
// el protocolo USB/IP. Un cliente USB/IP (usbip-win2 en Windows, usbip en
// Linux) conecta el dispositivo al sistema operativo, que lo ve como una
// impresora USB más: clase 07 (impresora), subclase 01, protocolo 02
// (bidireccional), con un endpoint bulk OUT (EP 1) y uno bulk IN (EP 2).
//
// Protocolo: https://docs.kernel.org/usb/usbip_protocol.html
package usbip
import (
"bytes"
"encoding/binary"
"errors"
"io"
"net"
"sync"
"unicode/utf16"
"escpos-emulator/internal/escpos"
)
const (
protocolVersion = 0x0111
opReqDevlist = 0x8005
opRepDevlist = 0x0005
opReqImport = 0x8003
opRepImport = 0x0003
cmdSubmit = 1
cmdUnlink = 2
retSubmit = 3
retUnlink = 4
dirOut = 0
dirIn = 1
epBulkOut = 1
epBulkIn = 2
speedFull = 2
errPipe = -32 // -EPIPE: STALL
errConnReset = -104 // -ECONNRESET: URB cancelada
maxInBuffer = 4096
maxTransfer = 16 << 20
)
// BusID es el identificador con el que se conecta el dispositivo
// (usbip attach -r <host> -b 1-1).
const BusID = "1-1"
// Device describe la identidad USB del dispositivo.
type Device struct {
VendorID uint16
ProductID uint16
Manufacturer string
Product string
Serial string
IEEE1284ID string // respuesta a GET_DEVICE_ID de la clase impresora
}
// DeviceFromProfile construye la identidad USB a partir del perfil.
func DeviceFromProfile(p escpos.Profile) Device {
return Device{
VendorID: p.USBVendorID,
ProductID: p.USBProductID,
Manufacturer: p.Maker,
Product: p.Model,
Serial: p.Serial,
IEEE1284ID: "MFG:" + p.Maker + ";MDL:" + p.Model + ";CMD:ESC/POS;CLS:PRINTER;DES:" + p.Name + ";",
}
}
// Server atiende clientes USB/IP.
type Server struct {
ln net.Listener
dev Device
printer *escpos.Printer
mu sync.Mutex
attached bool
}
// Listen abre el puerto (normalmente 3240) y atiende en segundo plano.
func Listen(addr string, dev Device, p *escpos.Printer) (*Server, error) {
ln, err := net.Listen("tcp", addr)
if err != nil {
return nil, err
}
s := &Server{ln: ln, dev: dev, printer: p}
go s.serve()
return s, nil
}
// Addr devuelve la dirección en la que escucha.
func (s *Server) Addr() net.Addr { return s.ln.Addr() }
// Close deja de aceptar conexiones.
func (s *Server) Close() error { return s.ln.Close() }
func (s *Server) serve() {
for {
conn, err := s.ln.Accept()
if err != nil {
if errors.Is(err, net.ErrClosed) {
return
}
continue
}
go s.handle(conn)
}
}
func (s *Server) handle(conn net.Conn) {
defer conn.Close()
for {
var h [8]byte
if _, err := io.ReadFull(conn, h[:]); err != nil {
return
}
switch binary.BigEndian.Uint16(h[2:]) {
case opReqDevlist:
reply := opHeader(opRepDevlist, 0)
reply = binary.BigEndian.AppendUint32(reply, 1)
reply = append(reply, s.deviceRecord()...)
reply = append(reply, 0x07, 0x01, 0x02, 0x00) // interfaz 0: clase, subclase, protocolo, relleno
if _, err := conn.Write(reply); err != nil {
return
}
case opReqImport:
var bus [32]byte
if _, err := io.ReadFull(conn, bus[:]); err != nil {
return
}
id := string(bytes.TrimRight(bus[:], "\x00"))
if id != BusID || !s.acquire() {
s.printer.Note("USB/IP: rechazada la conexión a %q desde %s (no existe o ya está conectado)", id, conn.RemoteAddr())
_, _ = conn.Write(opHeader(opRepImport, 1))
return
}
_, err := conn.Write(append(opHeader(opRepImport, 0), s.deviceRecord()...))
if err == nil {
s.runDevice(conn)
}
s.release()
return
default:
return
}
}
}
func (s *Server) acquire() bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.attached {
return false
}
s.attached = true
return true
}
func (s *Server) release() {
s.mu.Lock()
s.attached = false
s.mu.Unlock()
}
func opHeader(code uint16, status uint32) []byte {
b := binary.BigEndian.AppendUint16(nil, protocolVersion)
b = binary.BigEndian.AppendUint16(b, code)
return binary.BigEndian.AppendUint32(b, status)
}
// deviceRecord es la estructura usbip_usb_device (312 bytes, big endian).
func (s *Server) deviceRecord() []byte {
b := make([]byte, 312)
copy(b[0:256], "/sys/devices/platform/escpos-emulator/usb1/"+BusID)
copy(b[256:288], BusID)
binary.BigEndian.PutUint32(b[288:], 1) // busnum
binary.BigEndian.PutUint32(b[292:], 2) // devnum
binary.BigEndian.PutUint32(b[296:], speedFull)
binary.BigEndian.PutUint16(b[300:], s.dev.VendorID)
binary.BigEndian.PutUint16(b[302:], s.dev.ProductID)
binary.BigEndian.PutUint16(b[304:], 0x0100) // bcdDevice
b[306], b[307], b[308] = 0, 0, 0 // la clase se declara en la interfaz
b[309] = 1 // bConfigurationValue
b[310] = 1 // bNumConfigurations
b[311] = 1 // bNumInterfaces
return b
}
// ---------------------------------------------------------------------------
// Dispositivo conectado: intercambio de URBs.
type inURB struct {
seq uint32
length int
numPackets uint32
}
type devConn struct {
srv *Server
conn net.Conn
session *escpos.Session
wmu sync.Mutex // serializa las escrituras en conn
mu sync.Mutex
pendingIn []inURB
inBuf []byte
config byte
}
func (s *Server) runDevice(conn net.Conn) {
d := &devConn{srv: s, conn: conn}
d.session = s.printer.NewSession("USB "+conn.RemoteAddr().String(), d.pushIn)
defer d.session.Close()
var h [48]byte
for {
if _, err := io.ReadFull(conn, h[:]); err != nil {
return
}
var err error
switch binary.BigEndian.Uint32(h[0:]) {
case cmdSubmit:
err = d.submit(h)
case cmdUnlink:
err = d.unlink(h)
default:
return
}
if err != nil {
return
}
}
}
func (d *devConn) submit(h [48]byte) error {
seq := binary.BigEndian.Uint32(h[4:])
dir := binary.BigEndian.Uint32(h[12:])
ep := binary.BigEndian.Uint32(h[16:])
length := binary.BigEndian.Uint32(h[24:])
numPackets := binary.BigEndian.Uint32(h[32:])
var setup [8]byte
copy(setup[:], h[40:48])
if length > maxTransfer {
return errors.New("usbip: transferencia demasiado grande")
}
var data []byte
if dir == dirOut && length > 0 {
data = make([]byte, length)
if _, err := io.ReadFull(d.conn, data); err != nil {
return err
}
}
switch {
case ep == 0:
return d.control(seq, numPackets, setup, data)
case ep == epBulkOut && dir == dirOut:
d.session.Write(data)
return d.reply(seq, numPackets, 0, len(data), nil)
case ep == epBulkIn && dir == dirIn:
// El host deja una lectura pendiente; se completa cuando la
// impresora tenga algo que enviar (mientras, el USB responde NAK).
d.mu.Lock()
d.pendingIn = append(d.pendingIn, inURB{seq: seq, length: int(length), numPackets: numPackets})
d.mu.Unlock()
return d.drainIn()
default:
return d.reply(seq, numPackets, errPipe, 0, nil)
}
}
func (d *devConn) unlink(h [48]byte) error {
seq := binary.BigEndian.Uint32(h[4:])
target := binary.BigEndian.Uint32(h[20:])
d.wmu.Lock()
defer d.wmu.Unlock()
d.mu.Lock()
var status int32
for i, u := range d.pendingIn {
if u.seq == target {
d.pendingIn = append(d.pendingIn[:i], d.pendingIn[i+1:]...)
status = errConnReset
break
}
}
d.mu.Unlock()
b := make([]byte, 48)
binary.BigEndian.PutUint32(b[0:], retUnlink)
binary.BigEndian.PutUint32(b[4:], seq)
binary.BigEndian.PutUint32(b[20:], uint32(status))
_, err := d.conn.Write(b)
return err
}
// pushIn recibe las respuestas de la impresora (DLE EOT, GS I...).
func (d *devConn) pushIn(b []byte) {
d.mu.Lock()
d.inBuf = append(d.inBuf, b...)
if over := len(d.inBuf) - maxInBuffer; over > 0 {
d.inBuf = d.inBuf[over:]
}
d.mu.Unlock()
_ = d.drainIn()
}
// drainIn completa lecturas bulk IN pendientes con los datos disponibles.
func (d *devConn) drainIn() error {
d.wmu.Lock()
defer d.wmu.Unlock()
for {
d.mu.Lock()
if len(d.pendingIn) == 0 || len(d.inBuf) == 0 {
d.mu.Unlock()
return nil
}
u := d.pendingIn[0]
d.pendingIn = d.pendingIn[1:]
n := min(u.length, len(d.inBuf))
chunk := append([]byte(nil), d.inBuf[:n]...)
d.inBuf = d.inBuf[n:]
d.mu.Unlock()
if _, err := d.conn.Write(retSubmitPacket(u.seq, u.numPackets, 0, n, chunk)); err != nil {
return err
}
}
}
func (d *devConn) reply(seq, numPackets uint32, status int32, actual int, data []byte) error {
d.wmu.Lock()
defer d.wmu.Unlock()
_, err := d.conn.Write(retSubmitPacket(seq, numPackets, status, actual, data))
return err
}
func retSubmitPacket(seq, numPackets uint32, status int32, actual int, data []byte) []byte {
b := make([]byte, 48, 48+len(data))
binary.BigEndian.PutUint32(b[0:], retSubmit)
binary.BigEndian.PutUint32(b[4:], seq)
binary.BigEndian.PutUint32(b[20:], uint32(status))
binary.BigEndian.PutUint32(b[24:], uint32(actual))
// Igual que el servidor de Linux: se devuelve el mismo number_of_packets
// que envió el cliente (0 o 0xFFFFFFFF en transferencias no isócronas).
binary.BigEndian.PutUint32(b[32:], numPackets)
return append(b, data...)
}
// ---------------------------------------------------------------------------
// Endpoint 0: peticiones de control.
func (d *devConn) control(seq, numPackets uint32, setup [8]byte, data []byte) error {
reqType, req := setup[0], setup[1]
value := binary.LittleEndian.Uint16(setup[2:])
wLength := int(binary.LittleEndian.Uint16(setup[6:]))
var resp []byte
ok := false
switch reqType & 0x60 {
case 0x00:
resp, ok = d.standardRequest(reqType, req, value)
case 0x20:
resp, ok = d.classRequest(reqType, req)
}
if !ok {
return d.reply(seq, numPackets, errPipe, 0, nil)
}
if reqType&0x80 == 0 {
return d.reply(seq, numPackets, 0, len(data), nil)
}
if len(resp) > wLength {
resp = resp[:wLength]
}
return d.reply(seq, numPackets, 0, len(resp), resp)
}
func (d *devConn) standardRequest(reqType, req byte, value uint16) ([]byte, bool) {
switch req {
case 0x00: // GET_STATUS
if reqType&0x1F == 0 {
return []byte{0x01, 0x00}, true // autoalimentado
}
return []byte{0x00, 0x00}, true
case 0x01, 0x03, 0x05: // CLEAR_FEATURE, SET_FEATURE, SET_ADDRESS
return nil, true
case 0x06: // GET_DESCRIPTOR
return d.srv.dev.descriptor(byte(value>>8), byte(value))
case 0x08: // GET_CONFIGURATION
d.mu.Lock()
defer d.mu.Unlock()
return []byte{d.config}, true
case 0x09: // SET_CONFIGURATION
d.mu.Lock()
d.config = byte(value)
d.mu.Unlock()
return nil, true
case 0x0A: // GET_INTERFACE
return []byte{0x00}, true
case 0x0B: // SET_INTERFACE
return nil, value == 0
}
return nil, false
}
// classRequest atiende las peticiones de la clase impresora USB 1.1.
func (d *devConn) classRequest(reqType, req byte) ([]byte, bool) {
switch {
case reqType == 0xA1 && req == 0: // GET_DEVICE_ID
id := d.srv.dev.IEEE1284ID
n := len(id) + 2
return append([]byte{byte(n >> 8), byte(n)}, id...), true
case reqType == 0xA1 && req == 1: // GET_PORT_STATUS
st := d.srv.printer.Status()
v := byte(0x18) // bit 3: sin error, bit 4: seleccionada
if st.Blocked() {
v &^= 0x08
}
if st.Offline {
v &^= 0x10
}
if st.PaperEnd {
v |= 0x20
}
return []byte{v}, true
case reqType == 0x21 && req == 2: // SOFT_RESET
return nil, true
}
return nil, false
}
// descriptor devuelve los descriptores USB estándar (little endian).
func (dev Device) descriptor(typ, index byte) ([]byte, bool) {
switch typ {
case 1: // dispositivo
return []byte{
18, 1, 0x00, 0x02, // bLength, DEVICE, bcdUSB 2.00
0, 0, 0, 64, // clase/subclase/protocolo en la interfaz, bMaxPacketSize0
byte(dev.VendorID), byte(dev.VendorID >> 8),
byte(dev.ProductID), byte(dev.ProductID >> 8),
0x00, 0x01, // bcdDevice 1.00
1, 2, 3, // iManufacturer, iProduct, iSerialNumber
1, // bNumConfigurations
}, true
case 2: // configuración + interfaz + endpoints
if index != 0 {
return nil, false
}
return []byte{
9, 2, 32, 0, 1, 1, 0, 0xC0, 1, // CONFIGURATION: wTotalLength 32, 1 interfaz, autoalimentado
9, 4, 0, 0, 2, 0x07, 0x01, 0x02, 0, // INTERFACE: 2 endpoints, impresora bidireccional
7, 5, 0x01, 0x02, 64, 0, 0, // ENDPOINT 1 OUT, bulk, 64 bytes
7, 5, 0x82, 0x02, 64, 0, 0, // ENDPOINT 2 IN, bulk, 64 bytes
}, true
case 3: // cadenas
switch index {
case 0:
return []byte{4, 3, 0x09, 0x04}, true // idioma: inglés EE.UU.
case 1:
return stringDescriptor(dev.Manufacturer), true
case 2:
return stringDescriptor(dev.Product), true
case 3:
return stringDescriptor(dev.Serial), true
}
}
// DEVICE_QUALIFIER, BOS...: un dispositivo full speed USB 2.00 los rechaza con STALL.
return nil, false
}
func stringDescriptor(s string) []byte {
u := utf16.Encode([]rune(s))
b := []byte{byte(2 + 2*len(u)), 3}
for _, c := range u {
b = append(b, byte(c), byte(c>>8))
}
return b
}
+185
View File
@@ -0,0 +1,185 @@
package usbip
import (
"bytes"
"encoding/binary"
"io"
"net"
"testing"
"time"
"escpos-emulator/internal/escpos"
)
func startServer(t *testing.T) (*Server, *escpos.Printer) {
t.Helper()
prof, _ := escpos.FindProfile("80mm")
p, err := escpos.NewPrinter(prof)
if err != nil {
t.Fatal(err)
}
s, err := Listen("127.0.0.1:0", DeviceFromProfile(prof), p)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
return s, p
}
func dial(t *testing.T, s *Server) net.Conn {
t.Helper()
c, err := net.Dial("tcp", s.Addr().String())
if err != nil {
t.Fatal(err)
}
_ = c.SetDeadline(time.Now().Add(5 * time.Second))
t.Cleanup(func() { c.Close() })
return c
}
func readN(t *testing.T, c net.Conn, n int) []byte {
t.Helper()
b := make([]byte, n)
if _, err := io.ReadFull(c, b); err != nil {
t.Fatalf("leyendo %d bytes: %v", n, err)
}
return b
}
func TestDevlist(t *testing.T) {
s, _ := startServer(t)
c := dial(t, s)
c.Write([]byte{0x01, 0x11, 0x80, 0x05, 0, 0, 0, 0})
h := readN(t, c, 12)
if binary.BigEndian.Uint16(h[2:]) != opRepDevlist || binary.BigEndian.Uint32(h[8:]) != 1 {
t.Fatalf("cabecera DEVLIST inesperada: % X", h)
}
dev := readN(t, c, 312+4)
if got := string(bytes.TrimRight(dev[256:288], "\x00")); got != BusID {
t.Fatalf("busid = %q", got)
}
if !bytes.Equal(dev[312:], []byte{7, 1, 2, 0}) {
t.Fatalf("interfaz = % X, want clase impresora 07 01 02", dev[312:])
}
}
type client struct {
t *testing.T
c net.Conn
seq uint32
isIn map[uint32]bool
}
func attach(t *testing.T, s *Server) *client {
c := dial(t, s)
req := []byte{0x01, 0x11, 0x80, 0x03, 0, 0, 0, 0}
bus := make([]byte, 32)
copy(bus, BusID)
c.Write(append(req, bus...))
h := readN(t, c, 8)
if binary.BigEndian.Uint32(h[4:]) != 0 {
t.Fatalf("IMPORT rechazado: % X", h)
}
readN(t, c, 312)
return &client{t: t, c: c, isIn: map[uint32]bool{}}
}
func (cl *client) submit(dir, ep uint32, length int, setup []byte, data []byte) uint32 {
cl.seq++
cl.isIn[cl.seq] = dir == dirIn
h := make([]byte, 48)
binary.BigEndian.PutUint32(h[0:], cmdSubmit)
binary.BigEndian.PutUint32(h[4:], cl.seq)
binary.BigEndian.PutUint32(h[8:], 0x00010002)
binary.BigEndian.PutUint32(h[12:], dir)
binary.BigEndian.PutUint32(h[16:], ep)
binary.BigEndian.PutUint32(h[24:], uint32(length))
binary.BigEndian.PutUint32(h[32:], 0xFFFFFFFF)
copy(h[40:], setup)
cl.c.Write(append(h, data...))
return cl.seq
}
func (cl *client) ret() (seq uint32, status int32, data []byte) {
h := readN(cl.t, cl.c, 48)
seq = binary.BigEndian.Uint32(h[4:])
status = int32(binary.BigEndian.Uint32(h[20:]))
if n := int(binary.BigEndian.Uint32(h[24:])); cl.isIn[seq] && n > 0 {
data = readN(cl.t, cl.c, n)
}
return
}
func TestControlAndBulk(t *testing.T) {
s, p := startServer(t)
cl := attach(t, s)
// GET_DESCRIPTOR(DEVICE)
cl.submit(dirIn, 0, 18, []byte{0x80, 6, 0, 1, 0, 0, 18, 0}, nil)
_, st, d := cl.ret()
if st != 0 || len(d) != 18 || d[1] != 1 {
t.Fatalf("descriptor de dispositivo: status %d, % X", st, d)
}
if vid := binary.LittleEndian.Uint16(d[8:]); vid != 0x1209 {
t.Fatalf("VID = %04X", vid)
}
// GET_DESCRIPTOR(CONFIGURATION) con wLength 9: solo la cabecera
cl.submit(dirIn, 0, 9, []byte{0x80, 6, 0, 2, 0, 0, 9, 0}, nil)
_, _, d = cl.ret()
if len(d) != 9 || d[2] != 32 {
t.Fatalf("configuración: % X", d)
}
// GET_DEVICE_ID de la clase impresora
cl.submit(dirIn, 0, 1024, []byte{0xA1, 0, 0, 0, 0, 0, 0, 4}, nil)
_, _, d = cl.ret()
if !bytes.Contains(d, []byte("CMD:ESC/POS")) {
t.Fatalf("GET_DEVICE_ID = %q", d)
}
// DEVICE_QUALIFIER debe dar STALL
cl.submit(dirIn, 0, 10, []byte{0x80, 6, 0, 6, 0, 0, 10, 0}, nil)
if _, st, _ = cl.ret(); st != errPipe {
t.Fatalf("DEVICE_QUALIFIER status = %d, want %d", st, errPipe)
}
// Lectura bulk IN pendiente antes de que haya datos
inSeq := cl.submit(dirIn, epBulkIn, 64, nil, nil)
// Bulk OUT: texto + DLE EOT 1
payload := []byte("\x1b@Hola USB\n\x10\x04\x01")
outSeq := cl.submit(dirOut, epBulkOut, len(payload), nil, payload)
got := map[uint32][]byte{}
for i := 0; i < 2; i++ {
seq, st, data := cl.ret()
if st != 0 {
t.Fatalf("seq %d status %d", seq, st)
}
got[seq] = data
}
if !bytes.Equal(got[inSeq], []byte{0x12}) {
t.Fatalf("bulk IN = % X, want 12", got[inSeq])
}
if _, ok := got[outSeq]; !ok {
t.Fatal("falta la respuesta del bulk OUT")
}
// UNLINK de una lectura pendiente
pending := cl.submit(dirIn, epBulkIn, 64, nil, nil)
cl.seq++
u := make([]byte, 48)
binary.BigEndian.PutUint32(u[0:], cmdUnlink)
binary.BigEndian.PutUint32(u[4:], cl.seq)
binary.BigEndian.PutUint32(u[20:], pending)
cl.c.Write(u)
h := readN(t, cl.c, 48)
if binary.BigEndian.Uint32(h[0:]) != retUnlink || int32(binary.BigEndian.Uint32(h[20:])) != errConnReset {
t.Fatalf("RET_UNLINK inesperado: % X", h)
}
if len(p.Snapshot(0).Connections) != 1 {
t.Fatal("debería haber una conexión USB activa")
}
}
+85
View File
@@ -0,0 +1,85 @@
// Emulador de impresora térmica ESC/POS con conexión LAN (TCP 9100) y USB
// (a través de USB/IP).
package main
import (
"flag"
"fmt"
"os"
"os/signal"
"escpos-emulator/internal/escpos"
"escpos-emulator/internal/gui"
"escpos-emulator/internal/lan"
"escpos-emulator/internal/usbip"
)
func main() {
lanAddr := flag.String("lan", ":9100", "dirección TCP del puerto RAW; vacío para desactivar")
usbAddr := flag.String("usbip", ":3240", "dirección del servidor USB/IP; vacío para desactivar")
profileKey := flag.String("perfil", "80mm", "modelo emulado: 80mm o 58mm")
noGUI := flag.Bool("nogui", false, "sin ventana: registro por consola y tickets a PNG")
ticketDir := flag.String("tickets", "", "carpeta donde guardar cada ticket cortado como PNG (en -nogui: \"tickets\")")
flag.Parse()
prof, ok := escpos.FindProfile(*profileKey)
if !ok {
fmt.Fprintf(os.Stderr, "perfil desconocido %q. Disponibles:\n", *profileKey)
for _, p := range escpos.Profiles() {
fmt.Fprintf(os.Stderr, " %-6s %s\n", p.Key, p.Name)
}
os.Exit(2)
}
p, err := escpos.NewPrinter(prof)
if err != nil {
fatal("no se pudo crear la impresora: %v", err)
}
if *noGUI {
p.SetLogOutput(os.Stdout)
if *ticketDir == "" {
*ticketDir = "tickets"
}
}
if dir := *ticketDir; dir != "" {
p.SetOnTicket(func(t *escpos.Ticket) {
path, err := escpos.SaveTicketPNG(dir, t)
if err != nil {
p.Note("Error al guardar el ticket #%d: %v", t.Seq, err)
return
}
p.Note("Ticket #%d guardado en %s", t.Seq, path)
})
}
info := gui.Info{BusID: usbip.BusID}
if *lanAddr != "" {
srv, err := lan.Listen(*lanAddr, p)
if err != nil {
fatal("no se pudo abrir el puerto LAN %s: %v", *lanAddr, err)
}
info.LAN = srv.Addr().String()
p.Note("LAN: escuchando en %s (TCP RAW)", info.LAN)
}
if *usbAddr != "" {
srv, err := usbip.Listen(*usbAddr, usbip.DeviceFromProfile(prof), p)
if err != nil {
fatal("no se pudo abrir el puerto USB/IP %s: %v (¿está usbipd-win usando el 3240? prueba -usbip :3241)", *usbAddr, err)
}
info.USBIP = srv.Addr().String()
p.Note("USB/IP: escuchando en %s · conectar con: usbip attach -r <IP> -b %s", info.USBIP, usbip.BusID)
}
if *noGUI {
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt)
<-stop
return
}
gui.Run(p, info)
}
func fatal(format string, args ...any) {
fmt.Fprintf(os.Stderr, format+"\n", args...)
os.Exit(1)
}
BIN
View File
Binary file not shown.
+3
View File
@@ -0,0 +1,3 @@
module goruntime
go 1.26.3
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
package main
import "fmt"
func main() {
fmt.Println("hello world")
}
+11
View File
@@ -0,0 +1,11 @@
#include <stdio.h>
#include "escpos.h"
int main(void) {
const char *version = escpos_version();
printf("%s\n", version);
return 0;
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+6
View File
@@ -0,0 +1,6 @@
#ifndef ESCPOS_H
#define ESCPOS_H
const char *escpos_version(void);
#endif
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
#include "escpos.h"
const char *escpos_version(void) {
return "1.0.0";
}
static int some_function(void) {
return 1;
}