commit 3b0f5e365f8c08c104f970b6b6f505299ef801d4 Author: Pedro Pérez Date: Wed Sep 30 10:22:42 2026 +0200 initial commit diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1f4429a --- /dev/null +++ b/CLAUDE.md @@ -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. \ No newline at end of file diff --git a/docs/01-compilation-and-linking.md b/docs/01-compilation-and-linking.md new file mode 100644 index 0000000..590cb1d --- /dev/null +++ b/docs/01-compilation-and-linking.md @@ -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 ` 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. diff --git a/docs/02-pointers-and-strings.md b/docs/02-pointers-and-strings.md new file mode 100644 index 0000000..d1edd2b --- /dev/null +++ b/docs/02-pointers-and-strings.md @@ -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'`". diff --git a/docs/03-process-memory.md b/docs/03-process-memory.md new file mode 100644 index 0000000..c06503c --- /dev/null +++ b/docs/03-process-memory.md @@ -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//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. diff --git a/docs/04-go-with-c-mindset.md b/docs/04-go-with-c-mindset.md new file mode 100644 index 0000000..b9875a0 --- /dev/null +++ b/docs/04-go-with-c-mindset.md @@ -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. diff --git a/docs/05-libraries-and-frameworks.md b/docs/05-libraries-and-frameworks.md new file mode 100644 index 0000000..6fe454d --- /dev/null +++ b/docs/05-libraries-and-frameworks.md @@ -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. diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..247e95d --- /dev/null +++ b/docs/README.md @@ -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. diff --git a/emulator/README.md b/emulator/README.md new file mode 100644 index 0000000..90b27cd --- /dev/null +++ b/emulator/README.md @@ -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 +sudo usbip attach -r -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 +``` + +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). diff --git a/emulator/escpos-emu.exe b/emulator/escpos-emu.exe new file mode 100644 index 0000000..82d9ec5 Binary files /dev/null and b/emulator/escpos-emu.exe differ diff --git a/emulator/go.mod b/emulator/go.mod new file mode 100644 index 0000000..efdc109 --- /dev/null +++ b/emulator/go.mod @@ -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 +) diff --git a/emulator/go.sum b/emulator/go.sum new file mode 100644 index 0000000..3036523 --- /dev/null +++ b/emulator/go.sum @@ -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= diff --git a/emulator/internal/escpos/export.go b/emulator/internal/escpos/export.go new file mode 100644 index 0000000..26f9252 --- /dev/null +++ b/emulator/internal/escpos/export.go @@ -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) +} diff --git a/emulator/internal/escpos/parser.go b/emulator/internal/escpos/parser.go new file mode 100644 index 0000000..fb05a0e --- /dev/null +++ b/emulator/internal/escpos/parser.go @@ -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) +} diff --git a/emulator/internal/escpos/printer.go b/emulator/internal/escpos/printer.go new file mode 100644 index 0000000..5b3aa77 --- /dev/null +++ b/emulator/internal/escpos/printer.go @@ -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" +} diff --git a/emulator/internal/escpos/printer_test.go b/emulator/internal/escpos/printer_test.go new file mode 100644 index 0000000..117776f --- /dev/null +++ b/emulator/internal/escpos/printer_test.go @@ -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) + } + } +} diff --git a/emulator/internal/escpos/render.go b/emulator/internal/escpos/render.go new file mode 100644 index 0000000..d9c5b2b --- /dev/null +++ b/emulator/internal/escpos/render.go @@ -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) +} diff --git a/emulator/internal/gui/gui.go b/emulator/internal/gui/gui.go new file mode 100644 index 0000000..27af952 --- /dev/null +++ b/emulator/internal/gui/gui.go @@ -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) +} diff --git a/emulator/internal/lan/lan.go b/emulator/internal/lan/lan.go new file mode 100644 index 0000000..9ed36dc --- /dev/null +++ b/emulator/internal/lan/lan.go @@ -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 + } + } +} diff --git a/emulator/internal/usbip/usbip.go b/emulator/internal/usbip/usbip.go new file mode 100644 index 0000000..21e1bc8 --- /dev/null +++ b/emulator/internal/usbip/usbip.go @@ -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 -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 +} diff --git a/emulator/internal/usbip/usbip_test.go b/emulator/internal/usbip/usbip_test.go new file mode 100644 index 0000000..4b1bcfe --- /dev/null +++ b/emulator/internal/usbip/usbip_test.go @@ -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") + } +} + diff --git a/emulator/main.go b/emulator/main.go new file mode 100644 index 0000000..7f83615 --- /dev/null +++ b/emulator/main.go @@ -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 -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) +} diff --git a/escpos.o b/escpos.o new file mode 100644 index 0000000..feb487a Binary files /dev/null and b/escpos.o differ diff --git a/goruntime/go.mod b/goruntime/go.mod new file mode 100644 index 0000000..20c647f --- /dev/null +++ b/goruntime/go.mod @@ -0,0 +1,3 @@ +module goruntime + +go 1.26.3 diff --git a/goruntime/goruntime.exe b/goruntime/goruntime.exe new file mode 100644 index 0000000..5113c50 Binary files /dev/null and b/goruntime/goruntime.exe differ diff --git a/goruntime/main.go b/goruntime/main.go new file mode 100644 index 0000000..c048119 --- /dev/null +++ b/goruntime/main.go @@ -0,0 +1,7 @@ +package main + +import "fmt" + +func main() { + fmt.Println("hello world") +} diff --git a/hello.c b/hello.c new file mode 100644 index 0000000..2f4f7c6 --- /dev/null +++ b/hello.c @@ -0,0 +1,11 @@ +#include +#include "escpos.h" + +int main(void) { + + const char *version = escpos_version(); + + printf("%s\n", version); + + return 0; +} \ No newline at end of file diff --git a/hello.exe b/hello.exe new file mode 100644 index 0000000..7d0ebc5 Binary files /dev/null and b/hello.exe differ diff --git a/hello.o b/hello.o new file mode 100644 index 0000000..aecef6c Binary files /dev/null and b/hello.o differ diff --git a/hello_static.exe b/hello_static.exe new file mode 100644 index 0000000..f786bd8 Binary files /dev/null and b/hello_static.exe differ diff --git a/include/escpos.h b/include/escpos.h new file mode 100644 index 0000000..ab9e6af --- /dev/null +++ b/include/escpos.h @@ -0,0 +1,6 @@ +#ifndef ESCPOS_H + #define ESCPOS_H + + const char *escpos_version(void); + +#endif \ No newline at end of file diff --git a/samples/hello_world.bin b/samples/hello_world.bin new file mode 100644 index 0000000..97ed6ba Binary files /dev/null and b/samples/hello_world.bin differ diff --git a/src/escpos.c b/src/escpos.c new file mode 100644 index 0000000..ea0e27e --- /dev/null +++ b/src/escpos.c @@ -0,0 +1,9 @@ +#include "escpos.h" + +const char *escpos_version(void) { + return "1.0.0"; +} + +static int some_function(void) { + return 1; +} \ No newline at end of file