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