Docs / Contact / sdks/micropython/api-reference
MicroPython Client API Reference
Complete method reference for the tendrl.Client class on MicroPython.
Lifecycle
Client(**kwargs)
Create a new client instance. See Configuration for all parameters.
from tendrl import Client
client = Client(debug=True)
client.start()
Start the client. Connects to WiFi/Ethernet, syncs NTP, fetches entity info, connects to MQTT, and begins background processing.
client.stop()
Stop the client gracefully. Disconnects MQTT and stops timers.
await client.async_stop()
Async version of stop (for async mode).
Publishing
client.publish(data, tags=None, entity="", write_offline=False, db_ttl=0)
Publish a message to Contact via MQTT.
| Parameter | Type | Default | Description |
|---|---|---|---|
data |
dict/str | — | Message payload |
tags |
list[str] | None |
Tags for flow routing (max 10) |
entity |
str | "" |
Target entity (empty = self) |
write_offline |
bool | False |
Also store locally for offline resilience (managed mode only) |
db_ttl |
int | 0 |
Offline storage TTL in seconds (0 = no expiry) |
@client.tether(write_offline=False, db_ttl=None, tags=None, entity="")
Decorator that publishes the return value of a function.
@client.tether(tags=["sensor"])
def read_temp():
return {"temp": 25.5}
read_temp() # Auto-publishes
Works with both sync and async functions.
State Table
The state table is a persistent key-value store for the entity, sent over MQTT. These methods publish immediately (they are not queued or stored offline).
client.update_state(data, tags=None)
Merge data into the entity's existing state table. Only the supplied keys change.
client.update_state({"mode": "standby"})
client.replace_state(data, tags=None)
Replace the entity's entire state table with data.
client.replace_state({"firmware": "1.2.0", "mode": "active"})
There is no on-device method to read the state table back. To read the current state, fetch it over REST with GET /api/entities/status-table (see the REST Protocol Guide), or subscribe to remote state changes with a state_callback (below).
Message Receiving
Messages are received automatically via MQTT subscription. Route inbound messages with @client.on(), the inbound counterpart to @client.tether():
client = Client(debug=True)
@client.on(tag="ai-response")
def handle_ai_reply(message):
print(message.get("data"))
@client.on(tags=["alert", "anomaly"])
def handle_alert(message):
print(message.get("data"))
client.start()
@client.on(msg_type=None, tag=None, tags=None, tags_all=None)
Register a handler for incoming messages. All specified criteria must match (AND semantics). Routes are checked in registration order; first match wins.
| Parameter | Type | Description |
|---|---|---|
msg_type |
str | Match this message type (e.g. "publish") |
tag |
str | Match a single tag on the message |
tags |
list[str] | Match if the message has any of these tags |
tags_all |
list[str] | Match if the message has all of these tags |
@client.on(msg_type="publish", tag="config-update")
def apply_config(message):
apply_settings(message.get("data", {}))
@client.on(tags_all=["validated-fail", "sensor"])
def handle_validation_error(message):
client.update_state({"status": "needs_maintenance"})
@client.on_default
Catch-all handler for messages that don't match any @client.on() route:
@client.on_default
def unhandled(message):
print("No route:", message.get("msg_type"), message.get("tags"))
Fallback callback=
Pass callback= to the constructor for a catch-all handler. It runs when no @client.on() route or @client.on_default handler matches:
client = Client(callback=my_handler)
The handler receives a dict with message data, source, timestamp, and tags.
@client.on_state
Subscribe to remote state table updates over MQTT (MicroPython) or via polling at check_msg_rate (Python, Go, JavaScript). Register before client.start():
@client.on_state
def handle_state(state):
if state.get("status") == "needs_maintenance":
client.update_state({"local_status": "checking"})
Fallback state_callback=
Constructor argument. Runs when no @client.on_state handler is registered:
client = Client(state_callback=my_state_handler)
Called when the entity's state table is updated remotely.
Streaming
client.start_streaming(capture_frame_func=None, target_fps=15, quality=70, framesize="QVGA", stream_duration=-1)
Start JPEG video streaming. Requires async mode and the optional streaming module.
| Parameter | Type | Default | Description |
|---|---|---|---|
capture_frame_func |
callable | None |
Function that returns a JPEG frame. If None, the built-in OpenMV camera capture is used (requires the sensor module). |
target_fps |
int | 15 |
Target frames per second |
quality |
int | 70 |
JPEG compression quality |
framesize |
str | "QVGA" |
Resolution: QQVGA, QVGA, VGA |
stream_duration |
int | -1 |
Stream length in seconds (-1 = run until stopped) |
File Transfer
Send and receive files over HTTP. Files are malware-scanned by Surface before delivery and deleted once downloaded. See File Transfer.
client.send_file(path=None, data=None, filename=None, dest="", tags=None, kind="")
Upload a file (from path or data bytes) to an entity (dest) or tag-routed automation (tags). kind="clip" marks a captured motion clip that persists for review. Returns the response dict or None.
client.check_files(limit=50) / client.download_file(transfer_id)
List clean files addressed to this entity, and download one's bytes.
client.send_clip_burst(capture_frame, dest="", tags=None, duration_s=10, fps=10, filename="clip.zip", scale=None, quality=85, meta=None)
Capture JPEG frames into a zip and upload as kind="clip" with meta.format=jpeg_burst. Contact transcodes the burst into an MP4 with browser playback controls. capture_frame is a zero-arg callable returning an OpenMV image; cam.snapshot from Vision satisfies this directly.
from tendrl.vision import Camera, Watch, motion
cam = Camera()
def on_change(state, prev, img):
if state == "motion":
client.send_clip_burst(cam.snapshot, duration_s=5, fps=8)
watch = Watch(client, detect=motion(), debounce=2, cooldown_s=30,
on_change=on_change)
while True:
watch.update(cam.snapshot())
client.send_clip(capture_frame, ...) (deprecated)
Legacy animated GIF capture (OpenMV gif module). Prefer send_clip_burst() for better quality and video controls.
Vision
Optional extension (package-vision.json), OpenMV only. Full reference: Vision.
Camera(mode="detect", framesize="QVGA", quality=90, window=None, skip_ms=2000)
Owns sensor setup and hides the OpenMV csi (current) vs sensor (legacy) split.
cam.snapshot()→image.Image: requiresDETECTmode (RGB565). Feeds detectors andsend_clip_burst().cam.jpeg()→bytes: requiresSTREAMmode (JPEG). Satisfiescapture_frame_func.cam.set_mode(mode): switch.cam.jpeg()requiresSTREAM; detectors requireDETECT.cam.lock_exposure(locked=True): freeze auto gain/exposure/white balance (useful during a clip).cam.reconfigure(framesize=None, quality=None): change framesize on this camera rather than building a second one. Framesize only: it deliberately cannot reset the sensor or set a window.
To stream frames you detected on, stay in DETECT and compress yourself; see Streaming frames you have detected on.
Building a second Camera, re-applying a window, or reconfiguring while a model is loaded can hang an OpenMV AE3 outright: REPL dead, USB dropped, and a soft reset won't bring it back. All three were measured on real hardware.
Safe pattern: free your detector first (drop the reference and gc.collect()), then call reconfigure(). If you need a different window, don't reconfigure; reset the board and build a fresh Camera, because a window is only safe immediately after the reset inside Camera().
reconfigure() clears self.window for that reason, so don't assume the frame shape; read it back with img.width() / img.height(). On the AE3's sensor those differ from the nominal framesize anyway (QVGA returns 320×200, not 320×240).
Detectors
Each returns detect(img) -> (state, confidence) and owns its own state labels. A detector may return (None, 0.0) for "no verdict this frame".
| Detector | Notes |
|---|---|
roi_brightness(roi, threshold, states) |
Region brightness. No model, no heap. The workhorse. |
color_blob(thresholds, roi, min_pixels, states) |
Lamps, LEDs, signal towers. |
motion(threshold=0.02, scale=0.25, states) |
Frame differencing. threshold is a 0.0–1.0 fraction (RT1062: ~0.02 = person moving, ~0.10+ = near-total change). |
person(min_conf=0.4) |
OpenMV's in-flash model: weights in flash, ~275 KB arena to load, 0 per inference. |
face(min_conf=0.4) |
In-flash MediaPipe BlazeFace. ~390 KB arena, ~14 ms/frame (AE3). The cheapest model detector. |
palm(min_conf=0.4) |
In-flash MediaPipe BlazePalm. ~870 KB arena, ~44 ms/frame (AE3). Wants an NPU. |
hands(min_conf=0.4, palm_conf=0.4) |
Hand presence confirmed by the 21-joint landmark model (two models, ~1.8 MB). Wants an NPU. |
model_detect(path, min_conf, postprocess, states) |
Any TFLite/FOMO/YOLO model — a /rom model or a file on /flash, /sd, /app. Loads via load_model; flattens a classifier's (1, N) output. |
The in-flash models come from OpenMV firmware, not the SDK, so asking for one this firmware doesn't ship raises VisionError naming the file. The MediaPipe detectors want square input: Camera(framesize="VGA", window=(400, 400)).
load_model(path, kw):ml.Model(path, kw)for a model file. On the OpenMV N6
(firmware 5.0.0) a file-loaded model can fail with Failed to load network because its buffer lands in internal SRAM; load_model retries with the buffer in external RAM. On every other board — and for any other error — it is exactly ml.Model. See Custom vision models.
Boxes and keypoints
Geometry instead of a state, for drawing an overlay. All coordinates are image pixels.
| Function | Returns |
|---|---|
person_boxes(min_conf=0.4) |
[(x, y, w, h, score), ...] |
face_boxes(min_conf=0.4) |
+ keypoints as (6, 2): eyes, nose, mouth, ears |
palm_boxes(min_conf=0.4) |
+ keypoints as (7, 2): wrist, knuckles, thumb |
hand_landmarks(min_conf=0.4, palm_conf=0.4) |
+ keypoints, label as (21, 3) joints, "left"/"right" |
draw_boxes(img, boxes, label=None, color=(0,255,0), thickness=2): boxes into the pixels.draw_keypoints(img, keypoints, color=(255,0,0), radius=3): points only.draw_skeleton(img, keypoints, lines, kp_color, line_color, radius=3): points joined by
lines; pass PALM_LINES or HAND_LINES.
All three draw in place, so the overlay survives JPEG compression into a stream or clip.
roi_level(img, roi=None)
Mean brightness of a region as a 0.0–1.0 fraction, normalised across pixel formats. Use it to pick a threshold: print it in a loop while the thing you care about changes.
Watch(client, detect, debounce=3, cooldown_s=0, tags=None, on_change=None, entity="", publish=True, emit_initial=True, debug=False)
Debounces a detector and publishes only on transition: {"state": "empty", "prev": "full", "conf": 0.81}. A stable scene publishes nothing.
watch.update(img): feed one frame; returns the current confirmed state.watch.reset(): forget the state so the next confirmation publishes again.cooldown_s: a transition due mid-cooldown is deferred, not dropped.entity: which entity the message is addressed to (default""= this device);debugprints each transition.on_change(state, prev, img): runs after each publish.state= the new state that just fired,prev= the state before (Noneon the first change),img= the single frame that triggered it (for a clip, pass a capture function likecam.snapshottosend_clip_burst, not this frame).
Local Database
client.db_put(data, ttl=0, tags=None)
Store data in the local BTree database. Returns the generated key. ttl is in seconds (0 = no expiry).
There is no key parameter: keys are generated, so the client helpers cannot write a stable, overwritable row. For a fixed key (config, thresholds, anything you want to get back by name) create a standalone MicroTetherDB handle and use its key= argument.
client.db_get(key)
Retrieve data by key.
client.db_query(filter)
Query stored data. Returns a list of matching entries.
client.db_delete(key)
Delete an entry by key.
Utilities
tendrl.iso8601(timestamp=None)
Format a timestamp as ISO 8601 string. Uses current time if no argument.
tendrl.free(bytes_only=False)
Get memory and disk statistics:
{
"mem_free": 123456,
"mem_total": 520000,
"disk_free": 2000000,
"disk_size": 4000000
}
tendrl.sample_metrics(refresh_disk=True)
Sample the board's heap, filesystem and PSRAM in the shape the Device tab's live metrics rail consumes:
{"h": 132736, "a": 33856, "df": 1949696, "dt": 1966080, "p": None}
# h free heap a allocated heap
# df disk free dt disk total p free PSRAM (None if absent)
Picks the right filesystem root automatically (/flash on OpenMV, / elsewhere). Pass refresh_disk=False to skip the os.statvfs call and reuse the cached disk figures, which is worth doing in a loop, since heap is what moves.
tendrl.emit_metrics(refresh_disk=True)
Print a TDLMETRIC: line that the Device tab routes to the live metrics rail, hidden from the terminal scrollback. Call it from your own loop to keep the rail updating while your script owns the REPL:
from tendrl import emit_metrics
while True:
# ...your work...
emit_metrics(refresh_disk=False)
This is the metrics counterpart to preview.frame(). See Live metrics for how to read the result.
MQTT Details
The client uses MQTT QoS 1 (at least once delivery) for all messages. Connection is managed automatically with reconnection handled by the umqtt.robust library.
Topic Structure
| Topic | Direction | Purpose |
|---|---|---|
{account}/{region}/{apiKeyId}/publish |
Outbound | Publish data |
{account}/{region}/{apiKeyId}/messages |
Inbound | Receive messages |
{account}/{region}/{apiKeyId}/state |
Inbound | Receive retained state updates |
Topics are constructed automatically from the entity info returned by the API.
Authentication fields used by the broker:
client_id= entityresourcePath(subclaim)username=jti(apiKeyId)password= API key secret
Tendrl