WebSocket en tiempo real

Conéctate a wss://cliqer.io/ws para controlar una sala de Cliqer desde tu propio software o sistema de control de espectáculos, como si fuera un control remoto. Puedes cambiar diapositivas, manejar el temporizador del anfitrión, señalar con un punto láser y enviar mensajes al anfitrión. Tu aplicación se une a la sala como presentador; la sala siempre la aloja la aplicación de escritorio de Cliqer.
session_token (los navegadores la envían automáticamente), un parámetro de consulta ?token= o un encabezado Authorization: Bearer. El token se comprueba al conectarte, no en cada comando, así que cuando caduque abre una conexión nueva con un token nuevo y vuelve a unirte. Sin token puedes conectarte, pero no puedes usar la API. Las claves de API de equipo (cliqer_...) solo autentican los endpoints REST; en este socket una clave no es un token de sesión.Conexión
# WebSocket connections require a WebSocket client
# Using websocat for testing:
websocat "$WS_URL/ws?token=YOUR_ACCESS_TOKEN"
const ws = new WebSocket('$WS_URL/ws?token=' + accessToken)
ws.onopen = () => {
console.log('Connected to Cliqer WebSocket')
}
ws.onmessage = (event) => {
const message = JSON.parse(event.data)
console.log('Received:', message)
}
ws.onerror = (error) => {
console.error('WebSocket error:', error)
}
import websocket
import json
ws = websocket.WebSocketApp(
f"$WS_URL/ws?token={access_token}",
on_message=lambda ws, msg: print(json.loads(msg)),
on_error=lambda ws, err: print(f"Error: {err}"),
on_open=lambda ws: print("Connected")
)
ws.run_forever()
import (
"github.com/gorilla/websocket"
)
url := "$WS_URL/ws?token=" + accessToken
conn, _, err := websocket.DefaultDialer.Dial(url, nil)
if err != nil {
log.Fatal("dial:", err)
}
defer conn.Close()
for {
_, message, err := conn.ReadMessage()
if err != nil {
break
}
fmt.Printf("Received: %s\n", message)
}
use WebSocket\Client;
$client = new Client("$WS_URL/ws?token={$accessToken}");
while (true) {
$message = $client->receive();
$data = json_decode($message, true);
print_r($data);
}
use tokio_tungstenite::{connect_async, tungstenite::Message};
use futures_util::{StreamExt, SinkExt};
let url = format!("$WS_URL/ws?token={}", access_token);
let (mut ws_stream, _) = connect_async(&url).await?;
while let Some(msg) = ws_stream.next().await {
match msg? {
Message::Text(text) => {
let data: serde_json::Value = serde_json::from_str(&text)?;
println!("Received: {:?}", data);
}
_ => {}
}
}
El servidor envía connected con tu clientId en cuanto ha configurado la conexión. Espera a recibirlo antes de enviar nada: si un mensaje llega antes, se rechaza con un error y se cierra la conexión.
Flujo de conexión y sala
Navegación de diapositivas
Controla las diapositivas de una sala desde tu propio software, como si fuera un control remoto. Tu aplicación se une a la sala como presentador, así que el anfitrión la ve en la lista de presentadores con su presenterName y la gestiona como a cualquier otro presentador: el operador puede desactivar sus clics, expulsarla o bloquearla.
- Conéctate con un token de sesión (
?token=, un encabezadoAuthorization: Bearero la cookiesession_token). Las claves de API de equipo solo funcionan en los endpoints REST, no en este socket. - El plan del propietario de la sala debe incluir la API de cliente: el complemento API en Presenter, Studio HD y Studio 4K, ya incluido en Studio 8K y Enterprise.
- Los comandos de diapositivas y del temporizador solo se ejecutan mientras el anfitrión permita los clics de tu conexión.
Unirse a la sala
{ "type": "joinRoom", "payload": { "room": "ABC123", "presenterName": "Show control", "api": true } }
- Envíalo después del evento
connected: el servidor rechaza todo lo que llegue antes. presenterNamees el nombre que ve el anfitrión (por defecto,API). Tu aplicación siempre es un presentador en la sala, nunca su anfitrión.- Espera a recibir un evento
clicksEnabledcon payloadtrueantes de enviar comandos. El anfitrión lo envía en cuanto ha configurado tu conexión, y de nuevo cada vez que el operador activa o desactiva tus clics. - Guarda la
presenterKeydejoinedRoomy envíala enjoinRoomcuando vuelvas a conectarte, para que el anfitrión mantenga tu permiso de clics. - Envía la trama de texto
pingcada 15 segundos para mantener la conexión abierta. El servidor responde con la trama de textopong(no JSON).
Enviar comandos
{ "type": "webAction", "payload": { "action": "goto", "slide": 5, "id": "a3" } }
action | Campo adicional | Qué hace |
|---|---|---|
next | Diapositiva siguiente (o animación) | |
prev | Diapositiva anterior (o animación) | |
first | Primera diapositiva | |
last | Última diapositiva | |
goto | slide: número entero de 1 a 9999 | Va a esa diapositiva |
blackout | on: true o false | Pone la pantalla en negro o vuelve a mostrarla |
ides opcional: de 1 a 64 letras, dígitos,_,.,:o-. Se devuelve en el resultado. Usa unidnuevo para cada comando: el anfitrión descarta cualquier comando cuyoidya haya recibido de tu conexión en los últimos 5 segundos, así que un reintento del mismo comando se ejecuta una sola vez.first,last,gotoyblackoutsolo se ejecutan en un anfitrión cuyo eventocontrolsmás reciente los incluya ennav. Un anfitrión que no envía ningún eventocontrolssolo admitenextyprev. Si hay varios anfitriones en la sala, cada uno ejecuta los comandos que admite.nextoprevcon la pantalla en negro la vuelve a mostrar sin cambiar de diapositiva.- Un
gotomás allá de la última diapositiva se ignora. - Envía como máximo 10 comandos por segundo, sumando los de diapositivas y los del temporizador. El anfitrión también descarta la repetición del mismo comando en menos de 50 milisegundos.
Esto es lo que admite actualmente cada aplicación de presentaciones:
| Aplicación de presentaciones | next, prev | first, last | goto | blackout |
|---|---|---|---|---|
| PowerPoint en Windows | Sí | Sí | Sí | Sí |
| PowerPoint en Mac | Sí | Sí | No | No |
| Keynote | Sí | No | No | Sí |
| Google Slides (con la extensión del navegador conectada) | Sí | Sí | No | No |
| vMix | Sí | No | No | No |
| Ventana activa | Sí | No | No | No |
El evento controls da la información en directo del anfitrión que estás controlando: consulta nav en ese evento en lugar de fiarte de esta tabla.
# One connection: wait for `connected`, join as an API client, give the host a
# moment to set it up, then send commands. Replies print as they arrive.
( sleep 2
echo '{"type":"joinRoom","payload":{"room":"ABC123","presenterName":"Show control","api":true}}'
sleep 3
echo '{"type":"webAction","payload":{"action":"next","id":"a1"}}'
sleep 1
echo '{"type":"webAction","payload":{"action":"prev","id":"a2"}}'
sleep 1 ) | websocat "$WS_URL/ws?token=YOUR_SESSION_TOKEN"
const ws = new WebSocket('$WS_URL/ws?token=' + accessToken)
const send = (message: object) => ws.send(JSON.stringify(message))
let ready = false
ws.onmessage = (event) => {
if (event.data === 'pong') return
const msg = JSON.parse(event.data)
if (msg.type === 'connected') {
send({ type: 'joinRoom', payload: { room: 'ABC123', presenterName: 'Show control', api: true } })
setInterval(() => ws.send('ping'), 15000) // keeps the connection open
}
if (msg.type === 'clicksEnabled' && msg.payload === true && !ready) {
ready = true
send({ type: 'webAction', payload: { action: 'next', id: 'a1' } })
// Also: { action: 'prev' }, { action: 'first' }, { action: 'last' },
// { action: 'goto', slide: 5 }, { action: 'blackout', on: true }
}
if (msg.type === 'controls') console.log('Commands this host supports:', ['next', 'prev', ...msg.payload.nav])
if (msg.type === 'webActionResult') console.log(`${msg.payload.action} delivered to ${msg.payload.hosts} host(s)`)
if (msg.type === 'slide') console.log('Slide:', msg.payload?.number ?? 'no deck open')
if (msg.type === 'error') console.error(msg.error, msg.payload?.message)
}
import json
import threading
import time
import websocket
ready = False
def send(ws, message):
ws.send(json.dumps(message))
def keepalive(ws): # keeps the connection open
while ws.sock and ws.sock.connected:
time.sleep(15)
ws.send("ping")
def on_message(ws, message):
global ready
if message == "pong":
return
msg = json.loads(message)
if msg["type"] == "connected":
send(ws, {"type": "joinRoom", "payload": {"room": "ABC123", "presenterName": "Show control", "api": True}})
threading.Thread(target=keepalive, args=(ws,), daemon=True).start()
elif msg["type"] == "clicksEnabled" and msg["payload"] is True and not ready:
ready = True
send(ws, {"type": "webAction", "payload": {"action": "next", "id": "a1"}})
# Also: {"action": "goto", "slide": 5}, {"action": "blackout", "on": True}, ...
elif msg["type"] == "webActionResult":
print(f"{msg['payload']['action']} delivered to {msg['payload']['hosts']} host(s)")
elif msg["type"] == "slide":
print("Slide:", msg["payload"]["number"] if msg["payload"] else "no deck open")
elif msg["type"] == "error":
print("Error:", msg["error"], msg["payload"]["message"])
ws = websocket.WebSocketApp(f"$WS_URL/ws?token={access_token}", on_message=on_message)
ws.run_forever()
import (
"encoding/json"
"fmt"
"log"
"sync"
"time"
"github.com/gorilla/websocket"
)
conn, _, err := websocket.DefaultDialer.Dial("$WS_URL/ws?token="+accessToken, nil)
if err != nil {
log.Fatal("dial:", err)
}
defer conn.Close()
var writeMu sync.Mutex // one writer at a time
send := func(message interface{}) {
writeMu.Lock()
defer writeMu.Unlock()
conn.WriteJSON(message)
}
go func() { // keeps the connection open
for range time.Tick(15 * time.Second) {
writeMu.Lock()
conn.WriteMessage(websocket.TextMessage, []byte("ping"))
writeMu.Unlock()
}
}()
ready := false
for {
_, data, err := conn.ReadMessage()
if err != nil {
break
}
if string(data) == "pong" {
continue
}
var msg struct {
Type string `json:"type"`
Error string `json:"error"`
Payload json.RawMessage `json:"payload"`
}
if json.Unmarshal(data, &msg) != nil {
continue
}
switch msg.Type {
case "connected":
send(map[string]interface{}{
"type": "joinRoom",
"payload": map[string]interface{}{"room": "ABC123", "presenterName": "Show control", "api": true},
})
case "clicksEnabled":
if string(msg.Payload) == "true" && !ready {
ready = true
send(map[string]interface{}{
"type": "webAction",
"payload": map[string]interface{}{"action": "next", "id": "a1"},
})
// Also: {"action": "goto", "slide": 5}, {"action": "blackout", "on": true}, ...
}
case "webActionResult", "slide", "error":
fmt.Printf("%s %s %s\n", msg.Type, msg.Error, msg.Payload)
}
}
use WebSocket\Client;
use WebSocket\TimeoutException;
// A 15 second read timeout doubles as the keepalive timer.
$client = new Client("$WS_URL/ws?token={$accessToken}", ['timeout' => 15]);
$ready = false;
while (true) {
try {
$message = $client->receive();
} catch (TimeoutException $e) {
$client->text('ping'); // keeps the connection open
continue;
}
if ($message === null) break; // closed
if ($message === 'pong') continue;
$msg = json_decode($message, true);
if ($msg['type'] === 'connected') {
$client->text(json_encode([
'type' => 'joinRoom',
'payload' => ['room' => 'ABC123', 'presenterName' => 'Show control', 'api' => true]
]));
} elseif ($msg['type'] === 'clicksEnabled' && $msg['payload'] === true && !$ready) {
$ready = true;
$client->text(json_encode([
'type' => 'webAction',
'payload' => ['action' => 'next', 'id' => 'a1']
]));
// Also: ['action' => 'goto', 'slide' => 5], ['action' => 'blackout', 'on' => true], ...
} elseif (in_array($msg['type'], ['webActionResult', 'slide', 'error'], true)) {
echo json_encode($msg), PHP_EOL;
}
}
use futures_util::{SinkExt, StreamExt};
use serde_json::{json, Value};
use tokio_tungstenite::{connect_async, tungstenite::Message};
let url = format!("$WS_URL/ws?token={}", access_token);
let (ws_stream, _) = connect_async(&url).await?;
let (mut write, mut read) = ws_stream.split();
let every = std::time::Duration::from_secs(15);
let mut keepalive = tokio::time::interval_at(tokio::time::Instant::now() + every, every);
let mut ready = false;
loop {
tokio::select! {
_ = keepalive.tick() => write.send(Message::Text("ping".into())).await?, // keeps the connection open
frame = read.next() => {
let Some(frame) = frame else { break };
let Message::Text(text) = frame? else { continue };
if text.as_str() == "pong" { continue }
let msg: Value = serde_json::from_str(&text)?;
match msg["type"].as_str() {
Some("connected") => {
let join = json!({"type": "joinRoom", "payload": {"room": "ABC123", "presenterName": "Show control", "api": true}});
write.send(Message::Text(join.to_string().into())).await?;
}
Some("clicksEnabled") if msg["payload"] == true && !ready => {
ready = true;
let next = json!({"type": "webAction", "payload": {"action": "next", "id": "a1"}});
write.send(Message::Text(next.to_string().into())).await?;
// Also: {"action": "goto", "slide": 5}, {"action": "blackout", "on": true}, ...
}
Some("webActionResult") | Some("slide") | Some("error") => println!("{}", msg),
_ => {}
}
}
}
}
Resultados y eventos
Cada comando recibe como respuesta un webActionResult o un error.
{ "type": "webActionResult", "payload": { "action": "next", "id": "a1", "hosts": 1 } }
hosts es el número de anfitriones a los que se entregó el comando. La prueba de que se ejecutó es el siguiente evento slide del anfitrión; blackout también cambia blackout en el siguiente evento controls del anfitrión. Un comando que el anfitrión no ejecuta (porque los clics están desactivados, porque no admite ese comando o porque es un goto más allá de la última diapositiva) recibe un resultado, pero ningún evento slide.
error | Cuándo |
|---|---|
auth_required | joinRoom con api: true en una conexión sin token de sesión |
not_api_client | Un comando desde una conexión que no se ha unido a una sala con api: true, o que ya la ha abandonado |
invalid_web_action | action desconocida, o un slide, on o id que falta o no es válido |
invalid_timer_action | action de temporizador desconocida, o un minutes o id que falta o no es válido |
invalid_message | Un mensaje sin text o de más de 1000 caracteres |
invalid_presenter_mouse | Una posición del puntero fuera del rango de 0 a 1 |
rate_limited | Más de 10 comandos de diapositivas y del temporizador en un segundo, o más de 5 mensajes en 10 segundos |
no_host | No hay ningún anfitrión conectado a la sala |
host_update_required | Todos los anfitriones de la sala usan una versión antigua de Cliqer que no acepta comandos de la API |
api_not_in_plan | El plan del propietario de la sala no incluye la API de cliente |
Los errores incluyen un message legible y, si el comando estaba bien formado, su action y su id:
{ "type": "error", "error": "invalid_web_action", "payload": { "message": "Unknown or incomplete webAction. Use next, prev, first, last, goto with slide, or blackout with on.", "action": "goto", "id": "a3" } }
Los eventos de un anfitrión llevan el id de ese anfitrión en hostId:
| Evento | Payload |
|---|---|
clicksEnabled | true cuando el anfitrión acepta tus comandos, false cuando el operador ha desactivado tus clics |
controls | { app, nav, blackout, timer }: la aplicación de presentaciones, los comandos de diapositiva adicionales que admite (first, last, goto, blackout), si la pantalla está en negro y si el anfitrión acepta comandos del temporizador |
slide | { number, current, total, title, notes } después de cada comando que ha ejecutado el anfitrión (number es "4/12"; total es null cuando la aplicación no lo indica), o null cuando el anfitrión no tiene ninguna presentación abierta |
countdown | El tiempo que muestra el temporizador del anfitrión, como "4:59" ("-0:12" en tiempo extra), cada segundo mientras está en marcha, o null cuando se detiene |
countdownPhase | { phase, blink }: phase es idle, talk, warning (queda menos de un minuto), overtime o paused, y blink indica si el temporizador del anfitrión está parpadeando |
messageFromHost | Una respuesta del operador del anfitrión; consulta Mensajes al anfitrión |
También recibes los eventos propios de la sala (clientJoined, clientLeft y otros); ignora los que no uses. Si el operador expulsa o bloquea tu conexión, recibes logout con reason: "kicked".
Temporizador
Maneja el temporizador de la aplicación Cliqer del anfitrión con los mismos comandos que envía un Stream Deck o Bitfocus Companion.
{ "type": "timerAction", "payload": { "action": "set", "minutes": 25, "id": "t1" } }
action | Qué hace |
|---|---|
start | Inicia el temporizador o lo reanuda después de stop |
stop | Pausa el temporizador |
reset | Detiene el temporizador y vuelve al tiempo configurado |
plus1, plus2, plus5 | Añade 1, 2 o 5 minutos |
minus1, minus2, minus5 | Resta 1, 2 o 5 minutos |
preset5, preset10, preset15, preset20 | Detiene el temporizador y lo fija en 5, 10, 15 o 20 minutos |
set | Detiene el temporizador y lo fija en minutes, un número entero de 1 a 999 |
- Cada comando recibe como respuesta un
timerActionResult({ action, id, hosts }, conhostsigual que en las diapositivas) o unerror. La prueba de que se ejecutó es el siguiente eventocountdownocountdownPhasedel anfitrión. - El temporizador solo funciona en un anfitrión cuyo evento
controlsmás reciente tengatimer: true. Los anfitriones con una versión antigua de Cliqer no lo anuncian e ignoran los comandos del temporizador. - Los comandos del temporizador siguen las mismas reglas que los de diapositivas: el anfitrión solo los ejecuta mientras permita los clics de tu conexión, y comparten el límite de 10 comandos por segundo.
- La configuración del temporizador del anfitrión se aplica tal cual: un temporizador configurado en modo ascendente cuenta hacia arriba, y un temporizador que sigue a un temporizador de hardware conectado toma el tiempo de ese dispositivo.
Puntero láser
Muestra un punto láser en la pantalla compartida del anfitrión, igual que lo hace el teléfono de un presentador.
{ "type": "presenterMouse", "payload": { "mouse": { "x": 0.5, "y": 0.25 } } }
xeyindican la posición en la pantalla compartida desde su esquina superior izquierda, de0a1.- Envía
{ "pressed": false }como payload para ocultar el punto. Si no, desaparece entre 30 y 90 segundos después de tu última posición. - El punto solo se muestra mientras el anfitrión comparte una pantalla y tiene activado Puntero láser. Su color depende de tu
presenterName. - Envía como máximo 30 posiciones por segundo. Las posiciones que sobran se descartan sin error, y no hay ningún evento de resultado.
Mensajes al anfitrión
Envía un mensaje al operador del anfitrión. Aparece en el chat de presentadores del anfitrión igual que un mensaje enviado desde el teléfono de un presentador, y las respuestas del operador llegan como messageFromHost.
# Join as an API client, then send a message. Replies print as they arrive.
( sleep 2
echo '{"type":"joinRoom","payload":{"room":"ABC123","presenterName":"Show control","api":true}}'
sleep 3
echo '{"type":"messageToHosts","payload":{"text":"Two minutes to the break","id":"m1"}}'
sleep 30 ) | websocat "$WS_URL/ws?token=YOUR_SESSION_TOKEN"
// After joining (see Slide Navigation): send a message, print the replies.
send({ type: 'messageToHosts', payload: { text: 'Two minutes to the break', id: 'm1' } })
ws.addEventListener('message', (event) => {
if (event.data === 'pong') return
const msg = JSON.parse(event.data)
if (msg.type === 'messageToHostsResult') console.log(`Delivered to ${msg.payload.hosts} host(s)`)
if (msg.type === 'messageFromHost') console.log(`${msg.payload.fromPresenterName}: ${msg.payload.originalPayload.text}`)
})
# After joining (see Slide Navigation): send a message.
send(ws, {"type": "messageToHosts", "payload": {"text": "Two minutes to the break", "id": "m1"}})
# In on_message, print the replies:
# elif msg["type"] == "messageFromHost":
# print(f"{msg['payload']['fromPresenterName']}: {msg['payload']['originalPayload']['text']}")
// After joining (see Slide Navigation): send a message.
send(map[string]interface{}{
"type": "messageToHosts",
"payload": map[string]interface{}{"text": "Two minutes to the break", "id": "m1"},
})
// In the read loop, print the replies:
// case "messageFromHost":
// fmt.Printf("%s\n", msg.Payload)
// After joining (see Slide Navigation): send a message.
$client->text(json_encode([
'type' => 'messageToHosts',
'payload' => ['text' => 'Two minutes to the break', 'id' => 'm1']
]));
// In the read loop, print the replies:
// } elseif ($msg['type'] === 'messageFromHost') {
// echo $msg['payload']['originalPayload']['text'], PHP_EOL;
// After joining (see Slide Navigation): send a message.
let message = json!({"type": "messageToHosts", "payload": {"text": "Two minutes to the break", "id": "m1"}});
write.send(Message::Text(message.to_string().into())).await?;
// In the read loop, print the replies:
// Some("messageFromHost") => println!("{}", msg["payload"]["originalPayload"]["text"]),
Respuesta del anfitrión:
{
"type": "messageFromHost",
"payload": {
"fromClientId": "host_abc123",
"fromPresenterName": "Stage PC",
"originalPayload": { "text": "Thanks, got it" }
},
"hostId": "host_abc123"
}
texttiene de 1 a 1000 caracteres. Envía como máximo 5 mensajes cada 10 segundos.- Cada mensaje recibe como respuesta un
messageToHostsResult({ id, hosts }) o unerror. - El operador ve el mensaje con tu
presenterName, y el anfitrión muestra una notificación si tiene activado el ajuste Notificaciones.
Referencia de eventos
Eventos salientes (Cliente → Servidor)
| Evento | Descripción |
|---|---|
joinRoom | Se une a una sala como cliente de API con api: true (consulta Navegación de diapositivas) |
webAction | Control de diapositivas (consulta Navegación de diapositivas) |
timerAction | Control del temporizador (consulta Temporizador) |
presenterMouse | Posición del puntero láser (consulta Puntero láser) |
messageToHosts | Envía un mensaje al operador del anfitrión (consulta Mensajes al anfitrión) |
logout | Sale de la sala; la conexión sigue abierta y puede volver a unirse |
Eventos entrantes (Servidor → Cliente)
| Evento | Descripción |
|---|---|
connected | Se envía al abrirse la conexión con { clientId, userId, isAuthenticated } |
joinedRoom | Te has unido a la sala correctamente |
clientJoined | Alguien se ha unido a la sala |
clientLeft | Alguien ha salido de la sala |
webActionResult | Se ha entregado un webAction a los anfitriones de la sala |
timerActionResult | Se ha entregado un timerAction a los anfitriones de la sala |
messageToHostsResult | Se ha entregado un messageToHosts a los anfitriones de la sala |
clicksEnabled | Indica si un anfitrión acepta tus comandos |
controls | Qué admiten la aplicación de presentaciones y el temporizador de un anfitrión |
slide | La diapositiva actual de un anfitrión después de un comando |
countdown | El tiempo del temporizador de un anfitrión |
countdownPhase | La fase del temporizador de un anfitrión |
messageFromHost | Un mensaje del operador del anfitrión |
logout | El operador ha expulsado o bloqueado tu conexión (reason: "kicked") |
error | Mensaje de error |