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
- An OpenMV Cam (H7 Plus, RT1062, or any board with a camera)
- A Wi-Fi network
- A free Tendrl account with an entity and API key
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:
- Clips persist for review. Unlike regular files (deleted after the recipient downloads them), clips stay available for your plan's full retention window.
- 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.)
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:
{
"api_key": "YOUR_ENTITY_API_KEY",
"wifi_ssid": "YOUR_WIFI",
"wifi_pw": "YOUR_PASSWORD"
} Step 2: The script
That's the whole thing:
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:
- Debounce. A change has to hold for
debounceconsecutive frames before it counts. Single-frame flickers are ignored. - Transition-only publishing. When the state actually changes, it publishes once:
{"state": "motion", "prev": "still", "conf": 0.6}. While the scene is stable it sends nothing at all. - Cooldown. A floor between events. A transition that comes due mid-cooldown is deferred, not dropped.
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:
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.
# 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.
Tendrl