July 1, 2026 Hunter McGuire

Motion-triggered video clips with OpenMV and MicroPython

Detect motion, capture a clip, scan it for safety, transcode it server-side, and review it with play/pause/seek, all from a $65 camera board.

Live streaming is great for real-time monitoring, but it's overkill when you only care about events. A camera on a driveway, a wildlife trap cam, a warehouse motion sensor. These need to capture a few seconds of video when something happens, then let you review it later from anywhere.

This guide builds exactly that: motion triggers a 5-second clip, the clip is uploaded and malware-scanned, Contact transcodes it into a browser-playable MP4, and you review it in the dashboard with video controls.

What you'll need

How clips work in Contact

A clip is a file upload with kind=clip. It goes through the same pipeline as any file transfer (validated, malware-scanned by Surface, stored) but with two differences:

  1. Clips persist for review. Unlike regular files (deleted after the recipient downloads them), clips stay available for your plan's full retention window.
  2. Server-side transcode. A JPEG burst (a zip of sequential frames) is transcoded into an MP4 with a poster image. The dashboard renders it with native video controls.

Step 1: Install the SDK with vision

The vision extension gives you the camera, the detectors, and the debouncing. OpenMV firmware doesn't ship the on-device mip installer, so install it over USB with mpremote: it runs mip on your computer and copies the files to the board. (Or just pick the vision tier when you provision from the Contact Device tab or tendrl-dev-mcp.)

Install (run on your computer)
mpremote mip install https://app.tendrl.com/api/public/sdk/v1/latest/mpy/package-vision.json

Then drop your credentials on the board's flash:

/flash/config.json
{
  "api_key": "YOUR_ENTITY_API_KEY",
  "wifi_ssid": "YOUR_WIFI",
  "wifi_pw": "YOUR_PASSWORD"
}

Step 2: The script

That's the whole thing:

main.py: Motion clip capture
import time
from tendrl import Client
from tendrl.vision import Camera, Watch, motion

client = Client(mode="sync", debug=True)
client.start()
time.sleep(5)

cam = Camera()  # RGB565, ready for detection and clips

def on_change(state, prev, img):
    if state != "motion":
        return  # the scene going still again is not worth a video
    client.send_clip_burst(
        cam.snapshot,
        duration_s=5,
        fps=8,
        tags=["motion-alert"],
        meta={"zone": "driveway"},
    )

watch = Watch(
    client,
    detect=motion(threshold=0.02),
    debounce=2,        # two frames must agree, no single-frame flickers
    cooldown_s=30,     # at most one clip every 30s
    tags=["motion-alert"],
    on_change=on_change,
)

while True:
    watch.update(cam.snapshot())
    time.sleep_ms(200)

What Watch is doing for you

The naive version of this loop (snapshot, diff, compare to a threshold, upload) looks shorter but behaves badly. A raw detector flips on every transient: a shadow, a passing cloud, one bad exposure. You get a fleet of cameras spamming clips.

Watch is the difference between a demo and something you can leave running:

That matters for your bill as much as your inbox. Every file upload is malware-scanned and counts as one Surface scan against the sender's monthly allowance. Video is expensive; a state change is not. The detector's real job is deciding which rare moments are worth the bytes.

Step 3: Review clips in the dashboard

Open the Clips tab (or the entity's detail page). Each clip shows a poster thumbnail, a playable MP4 with play/pause/seek, the metadata you attached, and the scan verdict. Clips are retained for your plan's message-history window, then purged along with the backing storage object.

Route it to Slack

The clip went up with tags=["motion-alert"], and so did the state message. That tag is the entire integration: create a Strand workflow triggered by motion-alert, and it receives the event, can fetch the clip's poster, and posts to Slack with the zone and timestamp. No code on the device changes.

Not just motion

Motion is the blunt instrument. The same Watch loop takes any detector. Swap one line and the camera watches something else entirely:

Same loop, different question
from tendrl.vision import roi_brightness, color_blob, person

# Is a region light or dark? (a carafe emptying, a tank draining, a gate opening)
detect=roi_brightness(roi=(120, 80, 60, 90), threshold=0.45,
                      states=("full", "empty"))

# Is that indicator lamp lit?
detect=color_blob(RED_THRESHOLDS, states=("off", "fault"))

# Is a person there? (built-in model, weights in flash; ~275 KB arena to load)
detect=person(min_conf=0.4)

The classical detectors need no model, no training data, and no heap for weights. Reach for them first; they cover far more real problems than people expect.

Tuning clip quality

The settings used above (8 FPS, 5 seconds, QVGA, quality 85) produce clips around 200–400 KB, well within the per-file size limit. Only quality 85 is the send_clip_burst default; its own defaults are 10 FPS for 10 seconds, so this example trades length and smoothness for a smaller file.

Tuning parameters
# Longer clip, lower FPS: wildlife monitoring
client.send_clip_burst(cam.snapshot, duration_s=15, fps=4)

# Shorter clip, higher FPS: fast motion (traffic)
client.send_clip_burst(cam.snapshot, duration_s=3, fps=12)

# Scaled down for bandwidth-constrained links
client.send_clip_burst(cam.snapshot, duration_s=5, fps=8, scale=0.5, quality=70)

Summary

About fifteen lines of MicroPython gets you a motion-activated camera that debounces its own noise, captures clips, scans them for malware, transcodes them into playable video, and stores them for 30-day review. No video server, no S3 bucket, no FFmpeg pipeline. Change one line and it watches something else.

Coming soon Vision docs