Saltar al contenido
Volver a Ayuda

Integración de molinetes y hardware

Un molinete valida cada entrada contra Beepers en tiempo real y deja pasar a una persona por entrada válida. El lector lee el QR, un bridge (o un ESP32 a través del bridge) pregunta por WebSocket y el relé abre sólo si la respuesta es allowed: true para ese escaneo.

Protocolo: ws_json_v1

1. Cómo se arma

El bridge de referencia corre en una Raspberry Pi o mini-PC en el local. Lee los lectores (USB, RS485 o ESP32), habla con Beepers y acciona el relé de cada molinete.

Arquitectura recomendadadiagrama
QR reader (USB / RS485 / ESP32)
        │
        ▼
ESP32 or bridge (Raspberry Pi, mini-PC) ──wss + X-Turnstile-API-Key──▶ Beepers /ws/turnstile
        │
        ▼  allowed: true for this request_id
relay (NO contact, dry) ──▶ turnstile controller "OPEN / PASS / BUTTON" input
  • El relé va en su contacto NO (normalmente abierto) a la entrada OPEN / PASS / BUTTON del controlador del molinete: es un contacto seco, sin tensión. Si el relé muere, el molinete queda cerrado.
  • Fail-closed: si Beepers no responde y no hay un manifiesto sin conexión vigente (ver Modo sin conexión), el molinete no abre. Dejá siempre cableado el botón físico de apertura de emergencia.
  • Drivers de relé del bridge: esp32 (el ESP32 acciona su propio relé), serial-relay (placas USB tipo LCUS), http-relay (módulos de red, con presets para Shelly) y mock (sólo desarrollo).
  • Los ESP32 se conectan al bridge por TCP en la red del local con mensajes firmados (HMAC por dispositivo, ventana anti-replay). El ESP32 nunca recibe la API key de Beepers.

2. Registrar el dispositivo

En Beepers: Organización › Configuración › Molinetes › Registrar dispositivo. Hace falta el permiso organization.manage_turnstiles. Ahí mismo elegís qué tipos de entrada valida cada molinete por evento.

La API key se muestra una sola vez. Guardala en el bridge y en ningún otro lado: nunca en una app, una web pública ni un repo.

El panel genera el bloque .env y un lanes.json de ejemplo listos para pegar en el bridge.

3. Instalar el bridge

El bridge necesita Bun. Si la configuración tiene errores, los lista todos juntos y sale con código 2.

Instalar y arrancar el bridgeshell
# Download the zip from Beepers first: "Download bridge (.zip)"
unzip beepers-turnstile-bridge.zip
cd beepers-turnstile-bridge/turnstile-bridge   # esp32-firmware/ sits next to it
bun install
cp .env.example .env              # or paste the block generated in Beepers
cp lanes.example.json lanes.json  # one lane per turnstile: reader + relay
bun run start
curl http://127.0.0.1:9464/healthz   # {"status":"ok"}
  • Bajalo con Descargar bridge (.zip), acá abajo o en el panel de molinetes.
  • El zip trae el bridge con su README, .env.example y lanes.example.json, con ejemplos de cada lector y relé (Shelly, módulos de red, placas USB).
  • El firmware del ESP32 y su guía de cableado vienen en el mismo zip, en la carpeta esp32-firmware/ (ver su README).

4. Conexión

Si escribís tu propio cliente en vez de usar el bridge, conectate a wss://api.beepers.app/ws/turnstile con la API key en el header X-Turnstile-API-Key.

  • La key va sólo en el header. En la URL no se acepta: una query string termina en los logs de acceso.
  • Sin header o con una key inválida o revocada, el handshake responde 401 y no se abre el socket. Si Beepers no puede validar la key en ese momento (la base no responde), responde 503: reintentá con espera.
  • Cada dispositivo mantiene como máximo 2 sockets. El más viejo se cierra con el código 4001 cuando entra uno nuevo; no reconectes en bucle ante un 4001.
  • El servidor manda pings de WebSocket cada 25 s y corta si no recibe nada en 60 s. También podés mandar el mensaje ping: te responde pong con server_time, la hora del servidor en UTC.
Probar la conexión desde la terminalshell
# Handshake check: 101 Switching Protocols = key accepted, 401 = missing or revoked,
# 503 = the backend could not check the key right now (retry with a backoff)
curl -i https://api.beepers.app/ws/turnstile \
  -H "X-Turnstile-API-Key: $TURNSTILE_API_KEY" \
  -H "Connection: Upgrade" -H "Upgrade: websocket" \
  -H "Sec-WebSocket-Version: 13" -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ=="

# Interactive session: type one JSON message per line
websocat -H "X-Turnstile-API-Key: $TURNSTILE_API_KEY" wss://api.beepers.app/ws/turnstile
{"action":"validate","request_id":"test-1","data":{"qr_data":"AB2D4K9L","event_id":"<event uuid>"}}

5. Validar una entrada

Mandá en qr_data exactamente lo que leyó el lector: un código estático, un código corto de 8 caracteres o el QR dinámico de la app (ticketId|código). El backend lo interpreta; el cliente no tiene que adivinar.

  • request_id identifica cada escaneo. Las respuestas se emparejan sólo por request_id: así una respuesta tardía nunca abre el molinete para la persona siguiente.
  • Si el socket se corta antes de la respuesta, reenviá el mismo request_id al reconectar: durante 10 minutos el backend repite la decisión que ya tomó y la entrada no se quema dos veces.
  • Esperá al menos 10 s la respuesta (el bridge usa 12 s): el backend se toma hasta 10 s. Sin respuesta, el molinete queda cerrado.
  • Los códigos cortos rotativos que muestra la app necesitan event_id: sin él nunca se encuentran. Los códigos estáticos no lo necesitan.
Enviar una validaciónjson
{
  "action": "validate",
  "request_id": "6f1c2a9e-3b7d-4c1e-9a5f-0d8e7c6b5a43",
  "data": {
    "qr_data": "<the raw code the reader read>",
    "event_id": "<event uuid, needed for the app's rotating short codes>"
  }
}
Respuesta: puede pasarjson
{
  "action": "validate_response",
  "request_id": "6f1c2a9e-3b7d-4c1e-9a5f-0d8e7c6b5a43",
  "allowed": true,
  "ticket_info": {
    "ticket_id": "…",
    "ticket_type": "General",
    "user_name": "<holder's user id: the field does not carry the name yet>",
    "event_id": "…"
  }
}
Respuesta: no puede pasarjson
{
  "action": "validate_response",
  "request_id": "6f1c2a9e-3b7d-4c1e-9a5f-0d8e7c6b5a43",
  "allowed": false,
  "reason": "Entrada inexistente o ya usada",
  "error_code": "ticket_not_found_or_used"
}

6. Mensajes del servidor

config_changed avisa que cambiaron las reglas de tipos de entrada del molinete; el servidor las recarga solo. revoked avisa que la key fue revocada: el servidor cierra con 4003 y no hay que reconectar con esa key.

Otros mensajes del servidorjson
{"action":"connected","data":{"device_name":"Puerta Norte"}}  // right after the handshake
{"action":"pong","server_time":"2026-10-14T03:12:09.123Z"}     // answer to {"action":"ping"}
{"action":"error","request_id":"…","error":"…"}                 // unknown action
{"action":"config_changed"}  // the device's ticket-type rules changed; nothing to do
{"action":"revoked"}         // key revoked; the server closes with 4003 right after

7. Códigos de error

Cuando allowed es false, error_code dice por qué y reason trae un texto en castellano para mostrar en la puerta. Los marcados como reintentables pueden cambiar si reintentás con el mismo request_id; el resto es definitivo. Los cuatro últimos llegan en un mensaje error, como respuesta a los pedidos del modo sin conexión.

error_codeQué significa¿Reintentable?
ticket_not_found_or_usedLa entrada no existe, ya se usó (o agotó sus ingresos) o es de otro venue (la respuesta es la misma a propósito).No
invalid_validation_payloadFaltan datos, el código rotativo es inválido o venció (por ejemplo, un QR dinámico viejo) o el evento ya terminó.No
dynamic_qr_requiredLa entrada usa QR dinámico: hay que mostrar el código de la app, no el impreso.No
ambiguous_codeEl código es de un grupo: se escanea cada entrada por separado.No
ticket_type_not_allowedEste molinete no admite ese tipo de entrada (reglas del panel).No
device_not_allowedEl molinete no está habilitado para ese evento (es de otro venue).No
PRE_VALID_FROMLa entrada todavía no está habilitada para ingresar.No
ACCESS_BLOCKEDLa persona está en la lista negra de puerta.No
OUTSIDE_ENTRY_HOURSFuera del horario de ingreso del tipo de entrada.No
UNDERAGENo cumple la edad mínima del evento (sólo si el evento exige la edad en el escaneo).No
invalid_request_idrequest_id inválido: 1 a 64 caracteres de A-Z, a-z, 0-9, _ o -.No
invalid_request_dataEl mensaje no tiene la forma esperada (en manifest_request, un event_id que no es UUID).No
device_revokedEl molinete fue revocado; la conexión se cierra con 4003.No
validation_failedError interno al validar.Sí
device_rate_limitedMás de 1200 pedidos por minuto de este molinete; esperá un momento.Sí
request_in_flightEse request_id todavía se está procesando.Sí
ticket_busyOtra puerta está leyendo esa misma entrada en este instante.Sí
manifest_failedSin conexión: Beepers no pudo armar o mandar el manifiesto. Pedilo de nuevo.Sí
manifest_rate_limitedSin conexión: ya pediste un manifiesto completo de ese evento hace menos de 10 minutos. Pedí un delta con since_version.No
offline_busySin conexión: ese socket ya tiene 4 pedidos de manifiesto o de sincronización esperando. Reintentá en unos segundos.Sí
invalid_offline_scansSin conexión: scans no es una lista de 1 a 200 escaneos con los campos esperados.No

8. Ejemplos de cliente

Ejemplos mínimos para entender el protocolo. Para un molinete real usá el bridge: ya resuelve reconexión, timeouts, circuit breaker, relés y health checks.

Node.jsjavascript
// Node 18+ with the "ws" package. The key goes in a header, never in the URL.
import WebSocket from "ws"
import { randomUUID } from "node:crypto"

const ws = new WebSocket("wss://api.beepers.app/ws/turnstile", {
  headers: { "X-Turnstile-API-Key": process.env.TURNSTILE_API_KEY },
})
const pending = new Map() // request_id -> resolve

ws.on("message", (raw) => {
  const msg = JSON.parse(raw.toString())
  if (msg.action === "validate_response") {
    pending.get(msg.request_id)?.(msg)
    pending.delete(msg.request_id)
  } else if (msg.action === "revoked") {
    console.error("Key revoked or rotated: put the current key in and reconnect")
  }
})
ws.on("close", (code) => console.log("closed", code)) // 4001 replaced, 4003 revoked

export function validate(qrData, eventId) {
  const requestId = randomUUID()
  ws.send(JSON.stringify({ action: "validate", request_id: requestId, data: { qr_data: qrData, event_id: eventId } }))
  return new Promise((resolve) => {
    pending.set(requestId, resolve)
    // Wait at least 10 s: the backend may take that long. No answer = gate stays closed.
    setTimeout(() => pending.delete(requestId) && resolve({ allowed: false, error_code: "timeout" }), 12_000)
  })
}

// Open the gate only on allowed === true for this exact request_id.
Pythonpython
# Python 3.10+, websockets >= 14 (older versions call it extra_headers).
import asyncio, json, os, uuid
import websockets

async def main():
    async with websockets.connect(
        "wss://api.beepers.app/ws/turnstile",
        additional_headers={"X-Turnstile-API-Key": os.environ["TURNSTILE_API_KEY"]},
    ) as ws:
        print(json.loads(await ws.recv()))  # {"action": "connected", ...}

        request_id = str(uuid.uuid4())
        await ws.send(json.dumps({
            "action": "validate",
            "request_id": request_id,
            "data": {"qr_data": "AB2D4K9L", "event_id": os.environ["TURNSTILE_EVENT_ID"]},
        }))
        while True:
            msg = json.loads(await asyncio.wait_for(ws.recv(), timeout=12))
            if msg.get("action") == "validate_response" and msg.get("request_id") == request_id:
                print("OPEN" if msg.get("allowed") is True else f"DENIED: {msg.get('error_code')}")
                break

asyncio.run(main())

Modo sin conexión

Si Beepers no responde, el bridge no deja la puerta cerrada: decide con un manifiesto firmado que guarda en disco y sincroniza los ingresos cuando vuelve la conexión. Viene activado (OFFLINE_MODE=on); con OFFLINE_MODE=off el molinete queda cerrado mientras no haya conexión.

  • El bridge refresca el manifiesto del evento cada 60 s mientras hay conexión. Sólo funciona en los carriles que tienen event_id (o con TURNSTILE_EVENT_ID).
  • Sin conexión, el manifiesto sirve hasta OFFLINE_MANIFEST_MAX_AGE_S segundos desde que Beepers lo generó (21600 = 6 h por defecto), y nunca después de su vencimiento: 12 h desde que se generó o el cierre del evento, lo que llegue antes. Fuera de eso no se usa y el molinete queda cerrado.
  • Cada escaneo sin conexión entra en una cola local cifrada (OFFLINE_DIR, tiene que sobrevivir a un reinicio: nunca /tmp). Al reconectar, el bridge la sincroniza con Beepers.
  • Riesgo aceptado: sin conexión las puertas no se ven entre sí, así que una entrada puede pasar una vez por cada molinete. Beepers lo detecta al sincronizar y lo muestra en Configuración › Molinetes › Escaneos sin conexión, junto con los escaneos que rechazó.
  • El equipo del bridge necesita la hora sincronizada por NTP: el manifiesto y los QR dinámicos dependen del reloj.
  • Los QR dinámicos se validan sin conexión con una clave propia de cada entrada que viene en el manifiesto. El secreto global de Beepers nunca sale del servidor.

Checklist de producción

  • Siempre wss://. ws:// sólo en localhost o en un laboratorio.
  • Un dispositivo registrado por bridge, con su propia key. Para rotarla, usá Rotar clave en el panel: la anterior deja de andar en el acto y reiniciás el bridge con la nueva.
  • TURNSTILE_RESPONSE_TIMEOUT_MS en 12000 (mínimo 10000).
  • Si un lector emite códigos cortos, configurá event_id en el carril o TURNSTILE_EVENT_ID.
  • Monitoreá el bridge con /healthz y /metrics en 127.0.0.1:9464, sin exponerlos a internet.

Troubleshooting

  • 401 al conectar o /healthz dice revoked: key equivocada, rotada o dispositivo revocado. Rotá la clave (o registralo de nuevo) y reiniciá el bridge con la key nueva.
  • Se cierra con 4001: otro proceso usa la misma key. Dejá un solo bridge por dispositivo.
  • Los códigos cortos de la app rechazados con ticket_not_found_or_used: el carril no tiene event_id.
  • La validación dice allowed pero el molinete no gira: revisá el cableado COM/NO a OPEN y GND y probá un pulso más largo (pulse_ms 500).
  • device_rate_limited: el lector está mandando ráfagas; el bridge ya ignora la misma lectura repetida dentro de 0,9 s.

Todavía no disponible

Esto no está implementado; no cuentes con eso en la puerta:

  • Envío del log local del bridge a Beepers.
  • Actualizaciones OTA firmadas del bridge y del firmware del ESP32.