Ir al contenido

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"}}'