Guías de los SDK
Recorridos completos con el SDK de TypeScript y el de Python. En todos, tv es el cliente (new TeroVision({ apiKey }) o TeroVision(api_key)) y los ids de cámara son el id, no el device_uid. Los ejemplos están en sdk/js/examples/portal y sdk/python/examples/portal, y las pruebas de cada SDK los corren.
Comandos. Lo que se le pide a la cámara (ir a una posición, grabar, sacar una foto) contesta 202 con un command y corre después. waitForCommand / wait_for_command lo sigue hasta done; status dice qué pasó (done, failed, timeout, offline, rejected…) y actor quién lo pidió.
Cámaras
Listar todas (el SDK recorre las páginas), mirar su estado y dar de alta una nueva. El QR del alta lleva las credenciales de la cámara y se entrega una sola vez: la red WiFi se le agrega en tu equipo con withWifi, nunca viaja a TeroVision. El alta vence a los 30 minutos si la cámara no se conecta.
TypeScript / JavaScript — sdk/js/examples/portal/cameras.ts
// Every camera this key can see, across all pages.
for await (const camera of tv.cameras.all()) {
const status = await tv.cameras.status(camera.id);
console.log(camera.name, status.recording?.running ? 'recording' : 'idle');
}
// Provision a new camera. The QR comes back only once: treat it as a secret.
const { camera, provisioning } = await tv.cameras.create({ name: 'Depósito' });
const qr = withWifi(provisioning.qr_text, wifi.ssid, wifi.password); // Wi-Fi is added here, never sent to TeroVision
showQrToTheCamera(qr);
const online = await tv.cameras.waitOnline(camera.id, { timeoutMs: 15 * 60_000 });
Python — sdk/python/examples/portal/cameras.py
# Every camera this key can see, across all pages.
for camera in tv.cameras.all():
status = tv.cameras.status(camera["id"])
print(camera["name"], "recording" if status["recording"]["running"] else "idle")
# Provision a new camera. The QR comes back only once: treat it as a secret.
created = tv.cameras.create("Depósito")
qr = with_wifi(created["provisioning"]["qr_text"], ssid, password) # Wi-Fi is added here, never sent to TeroVision
show_qr_to_the_camera(qr)
online = tv.cameras.wait_online(created["camera"]["id"], timeout=15 * 60)
Dar de baja es DELETE /cameras/{id}: borra las credenciales y lo retenido, igual que en el panel.
PTZ
move es un paso de 500 ms que la cámara frena sola: para seguir moviendo se repite cada 300 ms, como el pad del panel. Ir a una posición, al inicio o a una posición guardada son comandos. Un movimiento por la API cuenta como uno a mano: pausa el seguimiento de personas un minuto y la ronda de la cámara (lo que diga su pausa).
TypeScript / JavaScript — sdk/js/examples/portal/ptz.ts
// `move` is a 500 ms step that the camera stops by itself: repeat it every 300 ms to keep moving.
for (let i = 0; i < 5; i++) {
await tv.ptz.move(cameraId, 'left');
await sleep(300);
}
await tv.ptz.stop(cameraId);
// Absolute moves and presets are commands: they answer 202 and run on the camera.
const goto = await tv.ptz.goto(cameraId, 1200, 300);
const result = await tv.cameras.waitForCommand(cameraId, goto.id);
console.log(result.status, result.actor); // "done", "api:<your key name>"
const { presets } = await tv.ptz.presets(cameraId);
const door = presets.find((p) => p.name === 'Puerta');
if (door) await tv.ptz.gotoPreset(cameraId, door.id);
Python — sdk/python/examples/portal/ptz.py
# `move` is a 500 ms step that the camera stops by itself: repeat it every 300 ms to keep moving.
for _ in range(5):
tv.ptz.move(camera_id, "left")
sleep(0.3)
tv.ptz.stop(camera_id)
# Absolute moves and presets are commands: they answer 202 and run on the camera.
goto = tv.ptz.goto(camera_id, 1200, 300)
result = tv.cameras.wait_for_command(camera_id, goto["id"])
print(result["status"], result["actor"]) # "done", "api:<your key name>"
door = next((p for p in tv.ptz.presets(camera_id)["presets"] if p["name"] == "Puerta"), None)
if door:
tv.ptz.goto_preset(camera_id, door["id"])
curl — resources/devportal/samples/curl/ptz.sh
curl -s -X POST -H "Authorization: Bearer $TEROVISION_API_KEY" -H 'Content-Type: application/json' \
"$API/cameras/$CAMERA_ID/ptz/move" -d '{"direction": "left"}'
curl -s -X POST -H "Authorization: Bearer $TEROVISION_API_KEY" "$API/cameras/$CAMERA_ID/ptz/stop"
# 202 with a command: poll GET /cameras/{id}/commands/{command} until "done": true
curl -s -X POST -H "Authorization: Bearer $TEROVISION_API_KEY" -H 'Content-Type: application/json' \
"$API/cameras/$CAMERA_ID/ptz/goto" -d '{"x": 1200, "y": 300}'
Guardar y borrar posiciones pide rol de administrador. Mientras la cámara sube una foto no atiende el PTZ.
Grabar
Configurar la grabación continua (y su subida al bucket) es de administrador; empezar y parar es operar. Las grabaciones aparecen en /recordings cuando el panel las encuentra en el bucket (cada 10 minutos), con una URL firmada que vence.
TypeScript / JavaScript — sdk/js/examples/portal/recording.ts
// Continuous recording to the SD card, uploading each finished segment to the space's bucket (admin role).
await tv.recording.update(cameraId, { enabled: true, upload: true, segment_minutes: 30 });
// Start and stop on demand (operate role).
const start = await tv.recording.start(cameraId, { segment_minutes: 30 });
await tv.cameras.waitForCommand(cameraId, start.id);
// What reached the bucket (indexed every 10 minutes), with short-lived signed URLs.
const { data } = await tv.recordings.list({ camera_id: cameraId, kind: 'recording', from: new Date(Date.now() - 86_400_000) });
const first = data[0];
if (first) {
const file = await tv.recordings.get(first.id, { download: true });
console.log(file.url, `valid for ${file.url_expires_in} s`);
}
Python — sdk/python/examples/portal/recording.py
# Continuous recording to the SD card, uploading each finished segment to the space's bucket (admin role).
tv.recording.update(camera_id, enabled=True, upload=True, segment_minutes=30)
# Start and stop on demand (operate role).
start = tv.recording.start(camera_id, segment_minutes=30)
tv.cameras.wait_for_command(camera_id, start["id"])
# What reached the bucket (indexed every 10 minutes), with short-lived signed URLs.
since = datetime.now(timezone.utc) - timedelta(days=1)
files = tv.recordings.list(camera_id=camera_id, kind="recording", since=since)["data"]
if files:
item = tv.recordings.get(files[0]["id"], download=True)
print(item["url"], f"valid for {item['url_expires_in']} s")
Video en vivo
POST /cameras/{id}/live es para tu servidor: devuelve una URL de LiveKit con un token de un solo espectador que solo se suscribe, y una URL HLS firmada. Las dos vencen y no se renuevan solas: se pide otra sesión un minuto antes, que vuelve a revisar el permiso. Al navegador se le pasa solo webrtc o hls, nunca la clave.
TypeScript / JavaScript — sdk/js/examples/portal/live.ts
// On your server: the session is view-only and expires; ask for a new one a minute before `expires_in`.
const session = await tv.live.create(cameraId, { protocols: ['webrtc', 'hls'] });
const forTheBrowser = { webrtc: session.webrtc, hls: session.hls }; // never the API key
Python — sdk/python/examples/portal/live.py
# On your server: the session is view-only and expires; ask for a new one a minute before `expires_in`.
session = tv.live.create(camera_id, protocols=["webrtc", "hls"])
for_the_browser = {"webrtc": session["webrtc"], "hls": session["hls"]} # never the API key
En el navegador, con livekit-client instalado, la ayuda @terovision/sdk/live pone el video en un <video>:
TypeScript / JavaScript — sdk/js/examples/portal/live-browser.ts
// In the browser (needs livekit-client). `fetchSession` calls YOUR server, which calls tv.live.create.
const session = await fetchSession();
const view = await joinLive(session.webrtc!, { element, muted: true });
// Renew before it expires: same camera, new token, permission checked again.
setTimeout(async () => {
await view.leave();
await liveInTheBrowser(fetchSession, element);
}, Math.max(5_000, view.expiresAt.getTime() - Date.now() - 60_000));
Hablar
Pide operar y una cámara con features.talk. El canal es semidúplex (mientras está abierto la cámara silencia su micrófono, también en la grabación y en "Hola Tero"), dura hasta 120 s y hay uno por cámara (si alguien está hablando, 409). Se mandan cuadros binarios de PCM s16le mono a 16 kHz, de 20 ms (640 bytes).
TypeScript / JavaScript — sdk/js/examples/portal/talk.ts
// Half duplex: while the channel is open the camera mutes its microphone. Up to 120 s per channel.
const channel = await tv.talk.open(cameraId);
const ws = new Ws(channel.ws_url); // open it within connect_within_s (60 s)
ws.binaryType = 'arraybuffer';
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
for await (const frame of pcm) {
ws.send(frame); // binary frames of channel.audio.frame_ms (20 ms) at channel.audio.sample_rate
}
ws.close();
await tv.talk.close(cameraId);
Python — sdk/python/examples/portal/talk.py
# Half duplex: while the channel is open the camera mutes its microphone. Up to 120 s per channel.
channel = tv.talk.open(camera_id)
with connect(channel["ws_url"]) as ws: # e.g. websockets.sync.client.connect; open it within connect_within_s
for frame in frames: # 640-byte binary frames: 20 ms of PCM s16le mono at 16 kHz
ws.send(frame)
tv.talk.close(camera_id)
Bucket
El bucket S3 (o compatible: MinIO, R2, Wasabi) del espacio. Las cámaras que se den de alta desde ahora suben ahí; las de antes no se enteran (baja y alta). secret_key es de solo escritura y el endpoint tiene que ser https y público: un nombre que resuelva a una red interna se rechaza.
TypeScript / JavaScript — sdk/js/examples/portal/storage.ts
// The space's own S3-compatible bucket (admin role). Cameras provisioned from now on upload there.
const { storage, test } = await tv.storage.set({
endpoint: 'https://s3.sa-east-1.amazonaws.com',
region: 'sa-east-1',
bucket: 'camaras-deposito',
access_key: secrets.accessKey,
secret_key: secrets.secretKey, // write-only: never returned again
test: true,
});
console.log(storage.bucket, storage.access_key_hint, test?.ok);
// Later: list, upload and delete a tiny object to check it still works.
const check = await tv.storage.test({ write: true });
Python — sdk/python/examples/portal/storage.py
# The space's own S3-compatible bucket (admin role). Cameras provisioned from now on upload there.
saved = tv.storage.set(
endpoint="https://s3.sa-east-1.amazonaws.com",
region="sa-east-1",
bucket="camaras-deposito",
access_key=access_key,
secret_key=secret_key, # write-only: never returned again
test=True,
)
print(saved["storage"]["bucket"], saved["storage"]["access_key_hint"], saved["test"]["ok"])
check = tv.storage.test(write=True) # list, upload and delete a tiny object
Webhooks
TeroVision le avisa a tu sistema cada evento de las cámaras. La clave de firma (whsec_…) se ve solo al crearlo. El catálogo de eventos y el formato están en Eventos y webhooks.
TypeScript / JavaScript — sdk/js/examples/portal/webhooks.ts
// The signing secret (whsec_…) is shown only here: store it like a password.
const hook = await tv.webhooks.create({ url: 'https://example.com/tero', events: ['camera.*', 'vision.result'] });
saveSecret(hook.id, hook.secret);
Python — sdk/python/examples/portal/webhooks.py
# The signing secret (whsec_...) is shown only here: store it like a password.
hook = tv.webhooks.create("https://example.com/tero", ["camera.*", "vision.result"])
save_secret(hook["id"], hook["secret"])
Del lado que recibe, verificá la firma con el cuerpo crudo, descartá entregas repetidas y contestá 2xx en menos de 5 segundos:
TypeScript / JavaScript — sdk/js/examples/portal/webhooks.ts
const seen = new Set<string>(); // use your database in production: delivery is "at least once"
return createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(chunk as Buffer);
const rawBody = Buffer.concat(chunks).toString('utf8'); // the raw body: re-serialized JSON breaks the signature
const ok = await verifyWebhook({ headers: req.headers as Record<string, string>, rawBody, secret });
if (!ok) {
res.writeHead(401).end();
return;
}
const delivery = String(req.headers['x-tero-delivery'] ?? '');
if (!seen.has(delivery)) {
seen.add(delivery);
onEvent(JSON.parse(rawBody));
}
res.writeHead(204).end(); // answer 2xx within 5 s; do slow work afterwards
});
Python — sdk/python/examples/portal/webhooks.py
# `raw_body` is the body exactly as received (request.get_data() in Flask, await request.body() in FastAPI).
if not verify_webhook(headers, raw_body, secret):
return 401
delivery = headers.get("X-Tero-Delivery") or headers.get("x-tero-delivery")
if delivery not in seen: # use your database in production: delivery is "at least once"
seen.add(delivery)
on_event(json.loads(raw_body))
return 204 # answer 2xx within 5 s; do slow work afterwards
Préstamo de control
Con el alcance control, una clave toma una cámara por hasta 5 minutos (renovables): mientras dura, las automatizaciones no la mueven ni la cambian, el seguimiento no la mueve y otra clave tampoco. Las personas del panel siguen pudiendo, y quien tiene configure lo puede liberar. Está explicado entero en API y automatizaciones.
La forma cómoda: hold toma el control, lo renueva solo a un tercio del plazo y lo suelta al terminar, también si algo falla.
TypeScript / JavaScript — sdk/js/examples/portal/leases.ts
// Take control for a while: automations will not move or change this camera until you release it.
try {
return await tv.leases.hold(cameraId, async ({ lease, signal }) => {
console.log(`in control as «${lease.holder}» until ${lease.expires_at}`);
for (const direction of ['left', 'left', 'up'] as const) {
if (signal.aborted) break; // a renewal failed: stop moving
await tv.ptz.move(cameraId, direction);
}
return tv.ptz.goto(cameraId, 1200, 300);
}, { ttlSeconds: 60 }); // renewed every 20 s, released at the end (also on errors)
} catch (err) {
if (err instanceof ConflictError && err.code === 'lease_held') {
console.log('another integration is driving this camera'); // err.body.error.lease says who and until when
return null;
}
throw err;
}
Python — sdk/python/examples/portal/leases.py
# Take control for a while: automations will not move or change this camera until you release it.
try:
with tv.leases.hold(camera_id, ttl_seconds=60) as lease: # renewed every 20 s, released on exit (also on errors)
print(f"in control as «{lease['holder']}» until {lease['expires_at']}")
for direction in ("left", "left", "up"):
if lease["lost"].is_set(): # a renewal failed: stop moving
break
tv.ptz.move(camera_id, direction)
return tv.ptz.goto(camera_id, 1200, 300)
except ConflictError as err:
if err.code != "lease_held":
raise
print("another integration is driving this camera") # err.body["error"]["lease"] says who and until when
return None
A mano:
TypeScript / JavaScript — sdk/js/examples/portal/leases.ts
const lease = await tv.leases.take(cameraId, { ttlSeconds: 300 }); // 10–300 s
await tv.leases.take(cameraId, { ttlSeconds: 300 }); // same key: renews
const { lease: current, held_by_this_key } = await tv.leases.get(cameraId);
await tv.leases.release(cameraId);
Python — sdk/python/examples/portal/leases.py
lease = tv.leases.take(camera_id, ttl_seconds=300) # 10-300 s
tv.leases.take(camera_id, ttl_seconds=300) # same key: renews
state = tv.leases.get(camera_id) # {"lease": {...}, "held_by_this_key": True}
tv.leases.release(camera_id)
curl — resources/devportal/samples/curl/lease.sh
# Take (201) or renew (200) control for up to 300 s. Another key gets 409 lease_held.
curl -s -X POST -H "Authorization: Bearer $TEROVISION_API_KEY" -H 'Content-Type: application/json' \
"$API/cameras/$CAMERA_ID/lease" -d '{"ttl_seconds": 120}'
curl -s -H "Authorization: Bearer $TEROVISION_API_KEY" "$API/cameras/$CAMERA_ID/lease"
curl -s -X DELETE -H "Authorization: Bearer $TEROVISION_API_KEY" "$API/cameras/$CAMERA_ID/lease"
Automatizaciones
Con automations:read se listan (sin el grafo: los flujos se editan en el panel), con automations:write se prenden y apagan (igual que el botón del panel; una administrada prende o apaga su función en la cámara) y con automations:run se ejecutan las que tienen el disparador Manual. Una ejecución por la API es de verdad, no una prueba, y pasa por los mismos topes, el mismo freno y los mismos permisos que cualquier otra.
TypeScript / JavaScript — sdk/js/examples/portal/automations.ts
// Read (automations:read): the graph is never returned; edit flows in the panel.
for await (const flow of tv.automations.all()) {
console.log(flow.name, flow.active ? 'on' : 'off', flow.runnable ? '(has a Manual trigger)' : '');
}
// Run one that has a Manual trigger (automations:run). It is a real run, not a test.
const flow = (await tv.automations.list()).data.find((a) => a.runnable && a.active);
if (flow) {
try {
const run = await tv.automations.run(flow.id, { camera_id: cameraId, data: { order: 'A-12' } });
const done = await tv.automations.waitForRun(flow.id, run.id);
for (const step of done.steps) console.log(step.node, step.status, step.code ?? '');
} catch (err) {
if (err instanceof RateLimitError && err.code === 'automation_capped') console.log('engine cap reached, try later');
else throw err;
}
// Turn it off and on (automations:write), through the same switch as the panel.
await tv.automations.disable(flow.id);
await tv.automations.enable(flow.id);
}
Python — sdk/python/examples/portal/automations.py
# Read (automations:read): the graph is never returned; edit flows in the panel.
for flow in tv.automations.all():
print(flow["name"], "on" if flow["active"] else "off", "(has a Manual trigger)" if flow["runnable"] else "")
# Run one that has a Manual trigger (automations:run). It is a real run, not a test.
flow = next((a for a in tv.automations.list()["data"] if a["runnable"] and a["active"]), None)
if flow:
try:
run = tv.automations.run(flow["id"], camera_id=camera_id, data={"order": "A-12"})
done = tv.automations.wait_for_run(flow["id"], run["id"])
for step in done["steps"]:
print(step["node"], step["status"], step["code"] or "")
except RateLimitError as err:
if err.code != "automation_capped":
raise
print("engine cap reached, try later")
# Turn it off and on (automations:write), through the same switch as the panel.
tv.automations.disable(flow["id"])
tv.automations.enable(flow["id"])
curl — resources/devportal/samples/curl/automations.sh
curl -s -H "Authorization: Bearer $TEROVISION_API_KEY" "$API/automations"
curl -s -X POST -H "Authorization: Bearer $TEROVISION_API_KEY" "$API/automations/$AUTOMATION_ID/disable"
curl -s -X POST -H "Authorization: Bearer $TEROVISION_API_KEY" "$API/automations/$AUTOMATION_ID/enable"
# Only automations with a Manual trigger. 202 with the run; follow it with GET .../runs/{run}.
curl -s -X POST -H "Authorization: Bearer $TEROVISION_API_KEY" -H 'Content-Type: application/json' \
"$API/automations/$AUTOMATION_ID/run" -d '{"camera_id": "'"${CAMERA_ID:-}"'", "data": {"order": "A-12"}}'