Use Case

Camera Streaming

Stream live video from an OpenMV camera directly to Tendrl. View real-time feeds in your dashboard, send sensor data alongside video, and trigger workflows when something happens.

Only care about events, not the live feed? A camera can run the detector on-device and send you 200 bytes when something changes, with no video leaving the board at all. See Cameras as Sensors.

Coming soon Streaming docs

Why embedded camera streaming is hard

Protocol complexity

MJPEG streaming over HTTP requires managing multipart boundaries, chunked encoding, and long-lived connections on a device with kilobytes of RAM.

Camera + networking

Running a camera capture loop and a network stack concurrently on a microcontroller demands cooperative multitasking, which is easy to get wrong and hard to debug.

Viewing infrastructure

You need a relay server, SSE or WebSocket endpoint, auth layer, and viewer client. Most teams end up with a fragile custom stack that only works on the local network.

Data alongside video

Cameras rarely work in isolation: you want sensor readings, GPS, battery level, and alerts alongside the video feed. That means two parallel data paths to build.

How Tendrl solves it

The MicroPython SDK handles camera configuration, streaming protocol, and Wi-Fi reconnection. You write six lines of code.

1

Set up your device

Create an entity in Contact with the entity:Stream permission. Add your Wi-Fi credentials and entity API key to config.json on the device's filesystem.

/flash/config.json (OpenMV)
{
  "api_key": "YOUR_ENTITY_API_KEY",
  "wifi_ssid": "YOUR_WIFI_SSID",
  "wifi_pw": "YOUR_WIFI_PASSWORD"
}
2

Start streaming with one call

The SDK auto-configures the camera (QVGA, JPEG, quality 70), connects to Wi-Fi, authenticates, and begins streaming. Default settings are optimized for stability: 15 FPS at 320x240 works reliably on most networks.

Basic streaming (OpenMV Cam)
import asyncio
from tendrl import Client

async def main():
    client = Client(mode="async", debug=True)
    client.start()
    await asyncio.sleep(5)

    # Camera auto-configured: QVGA, JPEG, quality 70
    client.start_streaming()

    try:
        await asyncio.sleep(3600)  # Stream for 1 hour
    except KeyboardInterrupt:
        pass
    finally:
        await client.async_stop()

asyncio.run(main())
3

Stream and send sensor data at the same time

Streaming runs as a background task on the same async event loop as MQTT messaging. Publish temperature, humidity, battery level, or any other data while the video streams; messaging takes priority so your telemetry is never blocked.

Streaming + messaging, simultaneous
import asyncio
from tendrl import Client

async def main():
    client = Client(mode="async", debug=True)
    client.start()
    await asyncio.sleep(5)

    # Start video stream as background task
    client.start_streaming()

    # Publish sensor data alongside the video
    while True:
        client.publish(
            {"temperature": 23.5, "status": "streaming"},
            tags=["sensors"]
        )
        await asyncio.sleep(10)

asyncio.run(main())
4

View the live feed in your dashboard

When a stream is active, a camera icon appears on the entity's detail page. Click it to open the live viewer. Tendrl relays frames via Server-Sent Events so any browser can view the feed: no plugins, no port forwarding, no local network required.

5

Trigger workflows from camera events

Use the same data flows and Strand workflows as any other entity. Tag messages with context so your workflows know when to fire.

Motion + alert Device detects motion → publishes alert with tag → Strand sends Slack notification with entity link
Periodic snapshot Scheduled workflow checks stream status → captures current frame → archives to S3
AI analysis Sensor data triggers workflow → Claude analyzes readings → posts summary alongside camera feed link
Anomaly detection DHT sensor flags out-of-range reading → workflow alerts with temperature data + camera feed URL

Tunable for your network and use case

Adjustable quality

JPEG quality 45–90. Lower quality means smaller frames and more bandwidth headroom. Default is 70; raise it toward 80–90 when you have the bandwidth.

Configurable FPS

Target 10–25 FPS. Lower FPS for slow networks, higher for smoother video. Default 15 FPS is stable on most Wi-Fi.

Multiple resolutions

QQVGA (160x120), QVGA (320x240), or VGA (640x480). Pick the right trade-off for your bandwidth.

Custom capture

Provide your own capture function for full control over camera settings, image processing, or non-standard camera modules.

Auto-reconnect

If Wi-Fi drops, the client reconnects automatically and resumes streaming. Check debug output for reconnection status.

Debug stats

With debug=True, get FPS, frame sizes, send times, and bandwidth stats every 60 frames.

Advanced: custom camera settings

For full control, use Camera in stream mode. It handles the OpenMV csi (current firmware) vs sensor (legacy) split, and cam.jpeg already satisfies the capture-function contract.

Custom capture at VGA resolution
import asyncio
from tendrl import Client
from tendrl.vision import Camera

cam = Camera(mode="stream", framesize="VGA", quality=60)

async def main():
    client = Client(mode="async", debug=True)
    client.start()
    await asyncio.sleep(5)

    client.start_streaming(capture_frame_func=cam.jpeg, target_fps=15)

    try:
        await asyncio.sleep(3600)
    finally:
        await client.async_stop()

asyncio.run(main())

The sensor holds one pixel format at a time. Hardware JPEG and the detectors want different ones (cam.jpeg needs stream mode, detectors need RGB565), so you can't run both off the sensor at once. You can still stream detected frames: stay in detect mode and compress in software, which is what the next section does.

Detect and stream

Stream what the model sees

The detectors return boxes and keypoints, and the draw helpers paint them into the frame's pixels, so they survive JPEG compression. Draw inside your capture function and the live feed carries the overlay with it. No server-side inference, no second video pipeline.

person_boxes

Where the people are. Uses OpenMV's built-in model: nothing to train or upload.

face_boxes

Face boxes plus six keypoints: both eyes, nose, mouth, both ears. The cheapest model; try it first on a board with no NPU.

palm_boxes

Hand boxes plus seven keypoints: wrist, knuckles, thumb. Draw as a skeleton with PALM_LINES.

hand_landmarks

All 21 joints per hand, labelled left or right. Pair with HAND_LINES for a full skeleton.

Detect, draw, then stream the annotated frame
import asyncio
from tendrl import Client
from tendrl.vision import Camera, face_boxes, draw_boxes, draw_keypoints

# Detect mode = RGB565, which is what the detectors read.
cam = Camera(mode="detect", framesize="QVGA")
detect = face_boxes(min_conf=0.5)

def annotated_jpeg():
    img = cam.snapshot()
    dets = detect(img)
    draw_boxes(img, dets, label="face")
    for d in dets:
        draw_keypoints(img, d[5])
    # Drawn into the pixels, so the boxes survive compression.
    return img.compress(quality=70).bytearray()

async def main():
    client = Client(mode="async")
    client.start()
    await asyncio.sleep(5)

    # Any callable returning JPEG bytes satisfies the contract.
    client.start_streaming(capture_frame_func=annotated_jpeg, target_fps=10)

    await asyncio.sleep(3600)

asyncio.run(main())

This compresses in software rather than using the sensor's JPEG encoder, and it still keeps up: measured on an OpenMV AE3 at QVGA, with the face model running every frame, one detect-draw-compress pass takes 43–50 ms (20 to 23 fps), comfortably above the 15 fps default target. The same draw calls work before send_clip_burst, which keeps the boxes in a recorded clip.

Pick the threshold before you write the code

Every detector takes a confidence or threshold you have to choose, and the right value depends on your scene, angle, and lighting. The Device tab's vision playground runs these same detectors on the board, live, so you can drag an ROI and watch the measured number move against the marker, then export the tuned watcher.

How the protocol works

OpenMV Cam JPEG frames
POST /api/stream multipart/x-mixed-replace
Tendrl relay Auth + broadcast
Dashboard viewer SSE → base64 JPEG
Device side

The SDK captures JPEG frames from the camera and sends them over a long-lived HTTP POST using the multipart/x-mixed-replace protocol. Each frame is a separate multipart part. The connection stays open as long as the device is streaming.

Relay

Tendrl authenticates the stream using the entity's API key, validates the entity:Stream permission, and broadcasts frames to connected viewers. Invalid frames are silently skipped. One stream per entity at a time.

Viewer side

The dashboard opens a Server-Sent Events connection to GET /api/entities/:id/stream/view. Each SSE data event contains a base64-encoded JPEG frame rendered into an <img> tag in real time.

Streaming limits

10 MB

Max frame size

30 FPS

Max frame rate

24 hours

Max connection

Idle timeout: 5 minutes with no frames. One active stream per entity. Streaming data counts toward your account's monthly data usage.

Compatible devices

OpenMV N6

Current flagship. Camera + on-device vision with plenty of headroom.

OpenMV Cam H7 Plus

Native hardware JPEG, fast capture, MicroPython built-in.

OpenMV RT1062

Plenty of headroom for streaming and on-device detection together.

OpenMV AE3

No hardware JPEG on the sensor; the SDK compresses in software automatically, so streaming still works.

Any MicroPython device

Any device that can capture JPEG and run the Tendrl SDK, via a custom capture function.

Stream your first camera feed in five minutes

Free tier includes 5 entities and 250 MB storage. No credit card required.

Coming soon Streaming docs