# Protocolo de terminal de MiFicha

Cómo conectar **cualquier** lector o dispositivo de fichaje con MiFicha: un lector de tarjetas que ya tenga la
empresa, un reloj de fichaje, una placa propia o un programa. Documento público para integradores y fabricantes.

Hay cuatro vías, de más a menos control:

| Vía | Para quién | Autenticación |
|---|---|---|
| **API de terminal** `/api/v1/terminal/…` | Dispositivos y programas que pueden hacer HTTPS y adaptarse a nuestro formato | Clave del terminal `mft_…` |
| **Webhook con plantilla** `/api/v1/entrada/<token>/` | Aparatos de terceros que solo saben mandar *su* formato a una URL | Token secreto en la URL |
| **ZKTeco ADMS** `/iclock/…` | Relojes ZKTeco y compatibles con «servidor en la nube» (push) | Número de serie (+ IP) |
| **Clientes hechos** | Terminal web (`/terminal/`), app MiFicha en modo terminal, agente MiFicha (Windows/Linux/Raspberry Pi) | Emparejamiento con código |

Base de la URL: `https://mificha.app` (o la de una instalación propia). Todo en UTF-8; JSON salvo ZKTeco.

---

## 1. Qué se lee: siempre texto

Un terminal **no decide** de quién es una lectura: manda el texto leído, tal cual, y MiFicha lo resuelve:

| Texto | Qué es | Notas |
|---|---|---|
| `MF1.<kid>.<ventana>.<firma>` | QR dinámico del móvil del trabajador | Cambia cada 15 s, firmado con Ed25519; un solo uso. Detalle en `docs/API.md` |
| `MF1C.<código>` | Tarjeta QR impresa por MiFicha | 22 caracteres base64url tras el prefijo |
| `PIN:<cifras>` | PIN tecleado en el terminal | Solo si el terminal «acepta PIN». 5 fallos seguidos bloquean el PIN 5 min en ese terminal |
| Cualquier otra cosa | Tarjeta o código propio de la empresa (RFID, banda, código de barras, PIN de un reloj…) | Se le aplica la **transformación** del terminal y se busca la credencial externa del trabajador |

**Transformación** (se configura en el panel por terminal, en este orden): recortar espacios y saltos de línea →
quitar prefijo y sufijo → expresión regular (se queda con el primer grupo) → solo dígitos → invertir bytes
(hexadecimal) → hexadecimal ↔ decimal → mayúsculas → ceros a la izquierda. Así un mismo carné leído por dos lectores
distintos (`0004A3F2` en uno, `304114` en otro, `ID:0004A3F2;` en un tercero) acaba siendo el mismo código.

Recomendación: **no limpies el texto en el dispositivo** (solo quita el fin de línea). Si lo transformas tú, hazlo
igual en todos los lectores de la empresa; si no, es más sencillo dejarlo al panel.

---

## 2. Emparejar y autenticarse

1. En el panel, **Terminales → Nuevo terminal**. Para la API y los clientes hechos aparece un **código de
   emparejamiento** de 8 caracteres (`ABCD-2345`, sin letras confusas), válido **10 minutos y una sola vez**. También
   se enseña como QR con el contenido `mificha://terminal?url=<servidor>&codigo=<código>` (para la app).
2. El dispositivo lo canjea por su **clave** (`mft_…`, 47 caracteres). MiFicha solo guarda su huella SHA-256: si se
   pierde, se pide otra en el panel (la anterior deja de valer).
3. Cada petición lleva `Authorization: Terminal mft_…`.

Para integraciones de servidor a servidor también se puede pedir en el panel la clave directamente («Ver una clave»).

### `POST /api/v1/terminal/emparejar/` (sin autenticación)

```json
{"codigo": "ABCD2345", "version": "mi-lector 2.1", "plataforma": "ESP32"}
```
Respuesta `200`:
```json
{"clave": "mft_Qe7…", "terminal": { …objeto terminal… }, "hora_servidor": "2026-03-02T09:00:00.123456+00:00"}
```
Errores: `400 codigo_incorrecto` (mal escrito, caducado o ya usado), `429 bloqueado` (más de 10 códigos erróneos
desde la misma IP en 15 min) o `429 demasiadas` (más de 20 peticiones por minuto).

### Objeto `terminal`

```json
{"id": 12, "nombre": "Entrada principal", "adaptador": "api", "modo_tipo": "automatico", "acepta_pin": false,
 "config": {"sonido": true}, "empresa": {"id": 3, "nombre": "Talleres Rías"}, "centro": {"id": 5, "nombre": "Vigo"},
 "zona_horaria": "Europe/Madrid", "modo_prueba": false, "modo_alta": null}
```
- `modo_tipo`: `automatico` (MiFicha alterna entrada y salida; no hace falta mandar `tipo`) o `botones` (el
  trabajador elige; manda `tipo`).
- `config`: parámetros del panel para ese tipo de terminal (los clientes hechos los usan; los tuyos pueden ignorarlos).
- `modo_alta`: nombre del trabajador al que se asignará la próxima tarjeta, o `null`.

---

## 3. API de terminal

| Método | Ruta | Cuerpo | Respuesta |
|---|---|---|---|
| `GET` | `/api/v1/terminal/` | — | `{"terminal", "hora_servidor"}` |
| `POST` | `/api/v1/terminal/lecturas/` | `{"lecturas": [lectura…], "reloj"?}` | `{"resultados": [respuesta…], "hora_servidor"}` |
| `POST` | `/api/v1/terminal/latido/` | `{"version", "reloj", "cola"}` | `{"hora_servidor", "modo_prueba", "modo_alta", "terminal"}` |

Cada petición actualiza en el panel el **último contacto** y la **IP** del terminal (se considera «en línea» si
ha hablado en los últimos 2 minutos: manda un latido cada 60 s).

### Lecturas

```json
{
  "reloj": "2026-03-02T09:00:05+01:00",
  "lecturas": [
    {"id_local": "6f1c2b2e-0d6e-4f0a-9a43-3d1b1f0c9a11", "texto": "0004A3F2", "leido_en": "2026-03-02T08:59:58+01:00"},
    {"id_local": "a8a3…", "texto": "MF1C.k3J9…", "tipo": "salida"}
  ]
}
```

| Campo | Obligatorio | Qué es |
|---|---|---|
| `texto` | sí | Lo leído, tal cual (máx. 500 caracteres) |
| `id_local` | muy recomendable | Id único generado en el terminal (UUID). Si llega dos veces, la segunda responde `duplicada` sin fichar: **reenviar es seguro** |
| `leido_en` | para lecturas sin conexión | Hora de lectura, ISO 8601 (con zona; sin zona = hora local del centro) o segundos/milisegundos Unix |
| `tipo` | no | `entrada`, `salida`, `pausa_inicio`, `pausa_fin`. Sin él, lo que toque |
| `reloj` | recomendable | Hora del terminal en el momento de enviar. MiFicha mide el desfase, lo enseña en el panel y **corrige `leido_en`** si el reloj va adelantado o atrasado más de 5 s |

- Hasta **200 lecturas** por envío. También se admite una sola lectura sin envolver (`{"texto": …}`).
- Las respuestas vienen en el mismo orden.

### Respuesta de cada lectura

```json
{"id_local": "6f1c…", "estado": "ok", "codigo": "", "senal": "ok", "trabajador": "Ana López",
 "tipo": "entrada", "hora": "08:59", "mensaje": "Entrada registrada"}
```

| `estado` | `senal` | Significado | Qué hacer en el terminal |
|---|---|---|---|
| `ok` | `ok` | Fichaje registrado (`tipo` y `hora` en la zona del centro). Si llegó tarde: «(diferido)» en el mensaje | Verde, pitido corto, nombre y tipo |
| `duplicada` | `aviso` | Ya fichado hace un momento (anti-rebote, 60 s por defecto) o `id_local` repetido | Ámbar; para la cola: **borrar**, ya está |
| `rechazada` | `error` | No se ha fichado: `codigo` y `mensaje` dicen por qué | Rojo, tres pitidos, el mensaje |
| `prueba` | `ok` | El terminal está en **modo prueba**: se ha reconocido pero no se ficha | Avisar «prueba» |
| `alta` | `ok`/`error` | **Modo alta**: la tarjeta se ha asignado (o no) al trabajador elegido en el panel | Avisar |

Con cualquier respuesta, la lectura **ya está procesada**: bórrala de la cola. Solo se reintenta si no hubo respuesta
(red, `5xx`, `429`).

Valores de `codigo` (los textos de `mensaje` vienen ya traducidos según `Accept-Language`: `es` o `gl`):

| `codigo` | Motivo |
|---|---|
| `desconocida` | Tarjeta, código o PIN que no es de nadie |
| `revocada` | Tarjeta o móvil anulados |
| `otra_empresa` | QR de un móvil de otra empresa |
| `caducado`, `firma`, `usado`, `formato` | QR del móvil caducado (revisar la hora del móvil), falso, ya usado o mal leído |
| `canal` | La empresa no permite fichar con el QR del móvil |
| `pin` | El terminal no acepta PIN |
| `pin_bloqueado` | Demasiados PIN incorrectos seguidos en este terminal |
| `vacio`, `leido_en` | Lectura vacía u hora ilegible |
| `sin_alta`, `empresa_inactiva`, `suscripcion` | Trabajador no está de alta ese día, empresa inactiva o sin suscripción |
| `ya_dentro`, `sin_entrada`, `en_pausa`, `no_en_pausa` | El `tipo` pedido no cuadra con el estado del trabajador |
| `duplicado`, `repetida` | Anti-rebote o `id_local` repetido (con `estado: duplicada`) |
| `futuro` | Hora de lectura posterior a la del servidor |
| `alta` | Error al asignar la tarjeta en modo alta (p. ej. ya es de otra persona) |

### Latido

```json
POST /api/v1/terminal/latido/
{"version": "mi-lector 2.1", "reloj": "2026-03-02T09:00:05+01:00", "cola": 0}
```
`cola` = lecturas pendientes de enviar (el panel avisa si crece). La respuesta trae `modo_prueba`, `modo_alta` y el
`terminal` actualizado: cambia la pantalla o el comportamiento si el administrador ha tocado algo.

### Errores HTTP

Siempre `{"error": {"codigo": "…", "mensaje": "…"}}`:

| HTTP | `codigo` | Cuándo |
|---|---|---|
| 400 | `json`, `lecturas`, `lote`, `reloj` | Cuerpo no JSON, sin lecturas, más de 200, `reloj` ilegible |
| 400 | `codigo_incorrecto` | Emparejar con un código malo |
| 401 | `no_autenticado` | Sin cabecera, clave mala o terminal desactivado (cabecera `WWW-Authenticate: Terminal`). **Deja de enviar y pide emparejar de nuevo; no borres la cola** |
| 429 | `demasiadas`, `bloqueado` | Límite de ritmo (600 peticiones/min por terminal por defecto; `Retry-After`) |
| 5xx | — | Fallo del servidor: reintenta más tarde |

---

## 4. Sin conexión y lecturas diferidas

El registro de jornada no puede perder fichajes porque se caiga la red. Todo terminal debería:

1. Al leer, generar `id_local` (UUID) y `leido_en` (su hora), **guardar en almacenamiento persistente** y después
   enviar.
2. Borrar de la cola solo lo que tenga respuesta (cualquier `estado`).
3. Reintentar lo pendiente con espera creciente (5 s, 10 s, 20 s… máx. 5 min) y al recuperar la red, en orden de
   lectura y en lotes.
4. Mandar `reloj` en cada envío y mantener la hora con NTP.
5. Avisar al trabajador de que la lectura **se ha guardado** aunque no haya respuesta (ámbar), sin decir que «ha
   fichado».

En el servidor: si una lectura llega más de **2 minutos** después de leerse, el marcaje se guarda con la hora de
lectura y marcado como **diferido** (visible en el registro y en la exportación para la Inspección). Una `leido_en`
más de 2 minutos en el futuro se ignora y se usa la hora del servidor.

---

## 5. Modo prueba y modo alta (desde el panel)

- **Prueba en vivo**: el administrador abre la prueba de un terminal; mientras está abierta (y como mucho 10 min
  sin ella), las lecturas **no fichan**: responden `prueba` con el nombre de quién sería, y el panel enseña el texto
  exacto recibido (con `␍ ␊ ␉`), cómo queda tras la transformación y de quién es. Ideal para configurar un lector.
- **Modo alta**: el administrador elige un trabajador; la **próxima** lectura de ese terminal (que no sea un QR de
  MiFicha ni un PIN) se le asigna como su tarjeta (ya transformada) y responde `alta`. Caduca a los 5 minutos.

---

## 6. Webhook con plantilla

Para aparatos que no se pueden programar y solo saben llamar a una URL en cada lectura con su propio formato.

1. Panel → Terminales → Nuevo terminal → «Dispositivo que envía a una URL». MiFicha da una URL secreta (una sola vez):
   `https://mificha.app/api/v1/entrada/<token>/`.
2. Ponla en el dispositivo. Se admite `POST`, `PUT` o `GET`, con cuerpo **JSON**, **formulario**
   (`application/x-www-form-urlencoded` o `multipart`), **texto plano** (el cuerpo entero es el código) o
   **parámetros en la URL** (que se mezclan con el cuerpo).
3. En la configuración del terminal, la **plantilla** dice dónde está cada dato:

| Campo | Ejemplo | Qué es |
|---|---|---|
| Código | `uid`, `datos.tarjeta.uid`, `lecturas.0.codigo` | Ruta con puntos (índices de lista con números). Vacío = se prueban `codigo, code, card, cardno, uid, tag, badge, barcode, qr, texto, data, pin` |
| Lista de eventos | `eventos` | Si un envío trae varios: cada elemento se procesa con las demás rutas |
| Id del evento | `event_id` | Para no duplicar si el aparato reintenta |
| Hora | `ts` | Formato: `auto` (ISO 8601 o Unix en s/ms), `iso`, `unix`, `unix_ms` o un patrón (`%d/%m/%Y %H:%M:%S`); sin zona = hora del centro |
| Tipo | `estado` | Con su tabla de equivalencias, una por línea: `0=entrada`, `1=salida`, `2=pausa_inicio`, `3=pausa_fin`. Sin tabla se entienden `entrada/in/checkin`, `salida/out/checkout`… |
| Respuesta | `json` / `texto` | JSON = la respuesta estándar (una lectura) o `{"resultados": […]}` (varias). Texto = `texto_ok` / `texto_error` (por defecto `OK` / `ERROR`), que admiten `{trabajador}`, `{tipo}`, `{hora}` y `{mensaje}`: útil para aparatos con pantalla de una línea |
| IP autorizadas | `203.0.113.0/24` | Opcional: solo se aceptan envíos desde ahí |

Ejemplo: el aparato manda
```http
POST /api/v1/entrada/9QeK…/ HTTP/1.1
Content-Type: application/json

{"event_id": 811, "datos": {"tarjeta": {"uid": "0004A3F2"}}, "ts": 1772438398, "evento": "0"}
```
con la plantilla `codigo = datos.tarjeta.uid`, `id = event_id`, `hora = ts` (`unix`), `tipo = evento` (`0=entrada`,
`1=salida`) → MiFicha contesta la respuesta estándar. Un `GET …/?card=0004A3F2` también vale.

Errores: `404 token` (URL mala o terminal desactivado), `403 ip`, `400 json`/`vacio`, `429 demasiadas`. Una lectura
que no se reconoce **no** es un error HTTP: es `200` con `estado: rechazada`.

---

## 7. ZKTeco ADMS («push»)

Muchos relojes ZKTeco (y compatibles iClock/eSSL) pueden llamar ellos mismos al servidor («servidor en la nube»,
«ADMS»). MiFicha implementa el protocolo push 2.x.

**En el panel**: Nuevo terminal → «ZKTeco» → número de serie (SN) del reloj (Menú → Sistema → Información).
**En el reloj**: Comunicación → Configuración del servidor en la nube: dirección del servidor = el dominio de MiFicha,
puerto `443` con HTTPS si el modelo lo admite (si no, `80`), sin proxy.

| Petición del reloj | Respuesta de MiFicha |
|---|---|
| `GET /iclock/cdata?SN=…&options=all&pushver=…` | La configuración: `GET OPTION FROM: <SN>`, `ATTLOGStamp`, `OPERLOGStamp=9999`, `ATTPHOTOStamp=9999`, `ErrorDelay=60`, `Delay=30`, `TransTimes`, `TransInterval=1`, `TransFlag=TransData AttLog` (solo marcajes), `TimeZone=<hora del centro>`, `Realtime=1`, `Encrypt=None` |
| `POST /iclock/cdata?SN=…&table=ATTLOG&Stamp=…` | Una línea por marcaje: `PIN⇥AAAA-MM-DD HH:MM:SS⇥estado⇥verificación⇥…` → `OK: <n>` |
| `POST /iclock/cdata?SN=…&table=OPERLOG` / `ATTPHOTO` / `options` | `OK` (se descartan sin guardar) |
| `GET /iclock/getrequest?SN=…` | `OK` (no se le mandan órdenes) |
| `POST /iclock/devicecmd?SN=…` | `OK` |
| `GET /iclock/ping?SN=…` | `OK` |

También con el sufijo `.aspx` (`/iclock/cdata.aspx`) que usan algunos firmwares. SN desconocido → `404`.

Decisiones:
- **El PIN del reloj es una tarjeta**: el número de usuario del reloj se busca como **credencial externa** del
  trabajador (con la transformación del terminal; por defecto se deja tal cual). Se da de alta con el **modo alta**
  (el trabajador ficha en el reloj una vez) o importando las credenciales. No se usa el número de empleado de
  MiFicha, para que el reloj y la plantilla puedan numerar distinto.
- **Estado** 0/1/2/3/4/5 → entrada / salida / inicio de pausa / fin de pausa / entrada / salida, **solo** si el
  terminal está en modo «con botones» (o con «Usar las teclas de estado del reloj»). Si no, MiFicha alterna entrada y
  salida (muchos relojes mandan siempre 0).
- **Nunca biometría**: MiFicha no pide ni guarda huellas, caras ni fotos (`OPERLOG`/`ATTPHOTO` se descartan). Los
  marcajes con verificación biométrica (huella 1, combinaciones 5–14, cara 15–20, palma 25) se **rechazan** con un
  aviso en el registro de lecturas: configura el reloj para tarjeta o contraseña.
- La hora del reloj se interpreta en la zona del **centro** del terminal. Los marcajes anteriores al alta del
  terminal (el histórico del reloj) se descartan; cada marcaje lleva `id_local = zk:<PIN>:<fecha>` y los reenvíos no
  duplican.
- **Seguridad**: el protocolo push no tiene clave; el SN lo identifica. Limita las IP en la configuración del
  terminal y usa HTTPS si el reloj lo admite. En instalaciones propias detrás de un proxy, deja `/iclock/` accesible
  por HTTP si el reloj no habla HTTPS.

---

## 8. Ejemplos

### curl

```bash
# Emparejar
curl -s https://mificha.app/api/v1/terminal/emparejar/ -H 'Content-Type: application/json' \
     -d '{"codigo": "ABCD2345", "version": "prueba 1.0", "plataforma": "curl"}'

# Mandar una lectura
curl -s https://mificha.app/api/v1/terminal/lecturas/ \
     -H 'Authorization: Terminal mft_…' -H 'Content-Type: application/json' \
     -d '{"lecturas": [{"id_local": "'"$(uuidgen)"'", "texto": "0004A3F2"}], "reloj": "'"$(date -Iseconds)"'"}'

# Latido
curl -s https://mificha.app/api/v1/terminal/latido/ -H 'Authorization: Terminal mft_…' \
     -H 'Content-Type: application/json' -d '{"version": "prueba 1.0", "reloj": "'"$(date -Iseconds)"'", "cola": 0}'
```

### Python (solo biblioteca estándar)

```python
import json, sqlite3, uuid, urllib.request
from datetime import datetime, timezone

SERVIDOR, CLAVE = 'https://mificha.app', 'mft_…'
db = sqlite3.connect('cola.sqlite3')
db.execute('CREATE TABLE IF NOT EXISTS cola (id_local TEXT PRIMARY KEY, texto TEXT, leido_en TEXT)')

def ahora():
    return datetime.now(timezone.utc).isoformat()

def api(ruta, datos):
    req = urllib.request.Request(f'{SERVIDOR}/api/v1/terminal/{ruta}', json.dumps(datos).encode(), method='POST',
                                 headers={'Content-Type': 'application/json', 'Authorization': f'Terminal {CLAVE}'})
    with urllib.request.urlopen(req, timeout=15) as r:
        return json.load(r)

def leer(texto):
    """Lo que hace el lector al leer: primero se guarda, luego se intenta enviar."""
    db.execute('INSERT INTO cola VALUES (?, ?, ?)', (str(uuid.uuid4()), texto, ahora()))
    db.commit()
    return enviar()

def enviar():
    filas = db.execute('SELECT id_local, texto, leido_en FROM cola ORDER BY leido_en LIMIT 200').fetchall()
    if not filas:
        return []
    try:
        r = api('lecturas/', {'reloj': ahora(), 'lecturas': [
            {'id_local': i, 'texto': t, 'leido_en': l} for i, t, l in filas]})
    except OSError:
        return []                       # sin conexión: se queda en la cola (urllib.error.URLError es un OSError)
    for res in r['resultados']:          # con respuesta, sea cual sea: procesada
        db.execute('DELETE FROM cola WHERE id_local = ?', (res['id_local'],))
    db.commit()
    return r['resultados']

print(leer('0004A3F2'))   # [{'estado': 'ok', 'trabajador': 'Ana López', 'tipo': 'entrada', 'hora': '08:59', …}]
```

Ejemplos completos: el **agente MiFicha** (`agente/`, Python) y el firmware **ESP32 + GM65**
(`agente/esp32/mificha_esp32.ino`, Arduino).

---

## 9. Buenas prácticas de hardware

- **Lectores USB «teclado»** (códigos de barras, QR, RFID de escritorio): configúralos con sufijo **Intro** (CR) y
  sin prefijo; distribución de teclado **US** (las cifras y letras salen igual en todas, los símbolos no). Para
  RFID, elige si el lector da el UID en **hexadecimal** o **decimal** y usa siempre el mismo en todos los lectores
  (o ajusta la transformación de cada terminal).
- **Lectores de QR**: para el QR del móvil hace falta un lector **2D** que lea pantallas (la mayoría de los actuales;
  los láser 1D no). Desactiva el modo «lectura repetida» o pon un retardo de ≥ 2 s para el mismo código.
- **RFID**: las tarjetas de 125 kHz (EM4100) se copian con facilidad; para más seguridad, Mifare DESFire o el QR
  dinámico del móvil (no se puede copiar: caduca en segundos y es de un solo uso).
- **Reloj**: sincroniza la hora con **NTP** y manda `reloj` en cada envío. El panel avisa si un terminal se desvía
  más de un minuto.
- **Sin conexión**: guarda siempre antes de enviar (§4). Un lector que solo funciona con red pierde fichajes.
- **Respuesta al trabajador**: verde + un pitido (fichado), ámbar + dos (aviso o guardado sin conexión), rojo +
  tres (no fichado, con el motivo). Enseña el nombre y la hora: así cada uno comprueba su fichaje.
- **Botones**: si el terminal está «con botones», el orden más natural es pulsar el botón y luego pasar la tarjeta
  (10 s de margen). Si la lectura llega sin botón, MiFicha decide (lo que toque).
- **Privacidad**: MiFicha **no usa biometría** (ni huella, ni cara, ni palma) para el registro de jornada. La
  biometría del propio móvil para desbloquear la app no sale del móvil.
- **Seguridad**: la clave `mft_…` es como una contraseña: guárdala fuera del alcance de los usuarios (no en una
  etiqueta ni en el código fuente público). Si un terminal se pierde, **desactívalo** en el panel.
