OnTapo
Async Python client for TP-Link Tapo cameras — over the cloud account, from anywhere.
Most Tapo tooling talks to a camera on your LAN, which means being on the same
network and knowing the camera's on-device password. OnTapo talks to the Tapo
cloud instead: it logs in with your account (MFA supported), discovers every
camera on the account, tunnels device methods over the cloud services-sync
passthrough, streams live video, drives pan/tilt, and pulls recordings off
the camera's own SD card over the cloud relay — no LAN access, no camera
password, and no Tapo Care subscription.
The protocol was reverse-engineered first-hand from the Tapo Android app
(v3.20.512) by capturing its own traffic against real cameras. See
docs/research/ for the captured method catalog.
⚠️ Educational use only — read this first
OnTapo is published for educational and research purposes only. It documents how an undocumented consumer IoT cloud protocol works; it is not a product, and it is not intended for production or commercial use.
- Not affiliated with, authorized by, or endorsed by TP-Link. "TP-Link" and "Tapo" belong to their respective owners.
- Provided "AS IS", without warranty of any kind. The author accepts no liability for any damage, data loss, account suspension, service interruption, or other consequence of using this software. You use it entirely at your own risk.
- Only use it on cameras you own, with your own credentials, in compliance with applicable law and TP-Link's Terms of Service — which may prohibit third-party clients.
- It relies on undocumented endpoints and can break permanently at any time. Do not depend on it for security, alarms, or evidence.
Full terms: DISCLAIMER.md and LICENSE.
Install
pip install ontapo
A complete example
examples/backup.py is a runnable program that logs in
(reusing a saved session), asks each camera what it supports, and downloads
yesterday's recordings — with the concurrency done correctly. Start there if you
prefer reading working code to reading an API list.
Quick start
import asyncio
from ontapo import OnTapo
async def main():
# First run: interactive MFA. The token can be persisted for headless reuse.
session = await OnTapo.login(
"me@example.com", "password",
mfa=lambda: input("Enter the code from your email: "),
)
async with session as s:
for dev in await s.devices():
print(dev.name, "→", await dev.sd_card_status())
asyncio.run(main())
Headless reuse (no MFA every time)
# after a successful login, persist:
import json, pathlib
path = pathlib.Path("session.json")
path.write_text(json.dumps(session.to_dict()))
path.chmod(0o600) # it holds a live cloud token — treat it like a password
# later, no login/MFA:
from ontapo import OnTapo
s = OnTapo.from_dict(json.loads(path.read_text()))
await s.refresh() # renew the token; also happens automatically on a 401
⚠️ The saved session grants full access to the account's cameras until it expires.
Keep it out of version control (this repo's .gitignore already excludes
session.json) and off shared machines.
What is this camera? (one call)
info = await dev.summary()
print(info.describe())
# Garage — TC65 (fw 1.9.2 Build 260519 Rel.13997n)
# pan/tilt: no
# SD card: normal — 29.5 GB, 99.2% used, 256.0 MB free (loop recording on)
# orientation: upright (no flip or rotation)
# features: 44 components
info.has_ptz # False — no pan/tilt motor
info.has_sd_card # True
info.supports("whiteLamp")
SD card
sd = await dev.sd_card_status()
sd.present # a card is detected
sd.healthy # detected and usable
sd.total_bytes # 31638716416 (exact, from the device)
sd.free_bytes # 268435456
sd.percent_used # 99.2
sd.nearly_full # True — normal with loop recording; oldest footage is overwritten
sd.loop_recording # True
sd.recording_since # datetime(2026, 8, 23, 15, 28, 23, tzinfo=UTC)
sd.summary # one-line description
total_space / free_space keep the device's own display strings ("29.5GB").
Live view — snapshot or video
Live view works over the cloud relay — no LAN, no P2P/STUN, no camera password:
await dev.snapshot("cat.jpg") # one still frame (needs ffmpeg)
await dev.live_preview("live.ts", 30) # 30s of live video as MPEG-TS
snapshot() pulls a few seconds of video and decodes one frame, because the
camera has no still-image endpoint. live_preview() needs no external tools.
What can this camera do?
Models differ. Ask the camera instead of guessing:
caps = await dev.capabilities() # {"ptz", "sdCard", "whiteLamp", ...}
if await dev.supports_ptz():
await dev.pan(80)
o = await dev.orientation()
print(o.description) # "flipped 180° (mounted upside-down)"
print(o.flipped) # True — the image is flipped, PTZ is NOT affected
Calling a method a model lacks raises UnsupportedMethod (a DeviceError
subclass), so you can branch either way.
Pan / tilt / presets
await dev.pan(80) # +/- pans in opposite directions
await dev.tilt(-40) # + tilts the view up
for p in await dev.presets():
print(p.id, p.name) # "1 Viewpoint 1"
await dev.move_to_preset("1")
Steps are relative and roughly proportional: 10 is a nudge, 150 a large sweep.
Recordings — mind the camera's calendar
The SD card indexes footage by the camera's local day, not UTC. A camera in Manila (UTC+8) starts a new recording day eight hours before a UTC clock does, so a range built from your own machine can be a whole day out. The library reads the camera's timezone and does this for you:
dev = s.device(device_id)
dates = await dev.recent_recording_dates(7) # range built from the camera's clock
clips = await dev.search_day(dates[-1]) # clip times in the camera's timezone
n = await dev.download(clips[0], "clip.ts") # raw MPEG-TS over the cloud relay
await dev.camera_today() # "20260828" — the camera's day, which may not be yours
await dev.camera_now() # tz-aware, as the camera sees it
Supply your own timezone instead of asking the camera (useful if the camera's clock is set wrong, or you want footage grouped by your days):
dev = s.device(device_id, tz="UTC+08:00") # no getTimezone call needed
dev.use_timezone("-0500") # or change it later
dev.use_timezone(None) # back to the camera's own setting
search_day() returns tz-aware datetimes in the camera's timezone so clips line up
with the day you asked for; pass as_utc=True for raw UTC. (A UTC view of the
27th on a UTC+8 camera appears to start at 17:06 on the 26th — an easy way to
misfile footage.) Note the camera's own clock cannot be changed over the cloud;
the gateway rejects setTimezone like every other setter.
download() writes raw MPEG-TS (.ts). Remux to MP4 with ffmpeg if you want a
standard container (Tapo audio is G.711 on a private stream type — see
ontapo.tsdemux for extracting it).
One download at a time, per camera. A camera serves a single record-download
session; while another client holds it — a second script, the Tapo app, a backup
service — every other request gets error_code -52405 until that client finishes.
Live view is unaffected throughout (that is how the two were told apart): the
relay fans one live stream out to as many viewers as you like.
| Operation | Same camera | Different cameras |
|---|---|---|
live_preview() / snapshot() |
✅ many at once | ✅ independent |
download() (playback) |
❌ one at a time | ✅ independent |
So: parallelise across cameras, serialise download() within one camera.
# Good: one worker per camera
await asyncio.gather(*(back_up(cam) for cam in cameras))
# Within a camera, one clip at a time — and be patient if something else is running
for clip in clips:
await dev.download(clip, f"{clip.start:%H%M%S}.ts", retries=6, retry_delay=10)
download() detects a refusal in about a second and retries with exponential
backoff (5s, 10s, 20s, 40s by default).
Any feature (generic passthrough)
Typed helpers cover the common controls. Anything else the app can do is one call away:
await s.request(device_id, "getLensMaskConfig", {"lens_mask": {"name": ["lens_mask_info"]}})
Errors
Everything raises from one hierarchy, so except OnTapoError catches the lot:
| Exception | Means |
|---|---|
AuthError / MFARequired |
login failed / needs a second factor |
Unauthorized |
token expired — refresh() or re-login (done automatically once on a 401) |
UnsupportedMethod |
this model doesn't have the feature (device error_code -40101) |
DeviceError |
the camera rejected the call (carries .method, .error_code) |
CloudError |
the gateway rejected it before the camera saw it (carries .status_code) |
RelayError |
relay negotiation or the media stream failed |
What works
Verified live against two real cameras on a real account with no Tapo Care subscription: a C216 (pan/tilt, no SD card, firmware 1.3.1) on 2026-08-27, and a TC65 (fixed, 29.5 GB SD card, firmware 1.9.2) on 2026-08-28. Everything below — live view, PTZ, recording search and download — worked without one.
| Area | Status |
|---|---|
| Login + MFA + region auto-detect | ✅ verified live |
| Token refresh / headless reuse | ✅ verified live |
| Camera discovery | ✅ verified live |
Generic services-sync passthrough |
✅ verified live |
| Typed config getters | ✅ verified live (18 of 20 on the C216; the other 2 are hardware it lacks) |
| Capability + orientation detection | ✅ verified live on both (C216 reports ptz, TC65 does not) |
| Live view — snapshot & video over the cloud relay | ✅ verified live (2304×1296 and 1920×1080 H.264) |
| PTZ — pan, tilt, presets | ✅ verified live (C216) |
Recording search (recording_dates, search_day) |
✅ verified live (TC65 — 217 clips in one day) |
| Recording download over the relay | ✅ verified live (TC65 — 1.65 MiB clip, 1920×1080 H.264 + audio) |
| Camera-local recording days / timezone handling | ✅ verified live (TC65 at UTC+8) |
| Config setters (privacy, motion, notifications, day/night) | ❌ rejected by the cloud gateway — see below |
| Two-way audio / talk-back | 🧪 not implemented |
Setters do not work over the cloud
The gateway validates each method name against a per-model schema and rejects
every set* call with HTTP 400 MODEL_SCHEMA_CHECK_FAILED before the camera
sees it — regardless of the params, the envelope, or the endpoint. Our capture
of the Android app contains no set* call over this transport either, so the app
evidently changes settings by another path.
The setter methods remain in the API (the param shapes match the getters, and
other models or regions may differ) but they raise CloudError here. Motor moves
are unaffected — motorMove and motorMoveToPreset are whitelisted and work.
Notes from the live run
- Unsupported ≠ broken. The C216 returns
-40101for line-crossing and intrusion detection because the hardware lacks them; that surfaces asUnsupportedMethod. - A 180° image flip does not reverse PTZ. On a camera mounted upside-down
(
flip_type="center"),tilt(+n)still moves the view up — the firmware compensates.Orientationreports the flip for information only. - A camera serves one record-download session at a time. A second client
holding it (another script, the app, a backup service) makes every other
playback request fail with
-52405until it finishes — diagnosed by finding live preview still working on the same camera at the same moment. The refusal is now detected immediately instead of waiting out a 12-second idle timeout, anddownload()retries with backoff.
Compatibility
This library has been verified against two camera models. Other Tapo cameras
very likely work — they speak the same cloud protocol — but which methods a given
model implements varies, and unsupported ones raise UnsupportedMethod.
If you run it against a different model, a compatibility report is the most useful
contribution you can make: open an issue with the output of
print((await dev.summary()).describe()), which carries the model, firmware, PTZ
support and SD-card state. See CONTRIBUTING.md.
Design
- Async (
asyncio+httpx) — concurrent multi-camera work and future live streaming. - One session per task —
OnTapois not concurrency-safe by contract. - The passthrough tunnel is the spine: every typed method is a thin wrapper over
session.request(device_id, method, params), so new methods are trivial to add as they're captured.
Development
python -m venv .venv && . .venv/bin/activate
pip install -e '.[dev]'
ruff check . && mypy src && pytest
The whole suite runs without a network or a camera: HTTP is mocked with respx,
MPEG-TS is built synthetically, and the relay tests run against a local socket
server.
To exercise the library against a real camera, there is an interactive harness — it prompts for everything, caches the session, and is read-only unless you pick a test that says otherwise:
python scripts/live_test.py
Before publishing anything, check that no personal data or credential has crept into a file or a commit (this repo ships protocol research, so that matters):
python scripts/check_no_secrets.py
Contributions are welcome — see CONTRIBUTING.md.
Disclaimer & license
MIT © Norielle Cruz — see LICENSE.
This project is for educational and research purposes only, is not affiliated with or endorsed by TP-Link, is provided "AS IS" with no warranty, and the author accepts no liability for any use of it or consequence arising from it. Use only on devices you own, with your own credentials, in compliance with applicable law. The full terms — including your responsibilities as a user and the reverse-engineering/interoperability basis for this work — are in DISCLAIMER.md.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file ontapo-0.1.0.tar.gz.
File metadata
- Download URL: ontapo-0.1.0.tar.gz
- Upload date:
- Size: 78.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
077d36d8f3d05992c93617addcac01d6ebe5a18eb0a0c16cbd4033bbd8905d63
|
|
| MD5 |
204785bb86f3680cbed7fcbc867ab265
|
|
| BLAKE2b-256 |
aa3eaa6d215aedc8163837d4e3fa581fdc2ddbbbde1325675b67bf7931f8ff37
|
Provenance
The following attestation bundles were made for ontapo-0.1.0.tar.gz:
Publisher:
release.yml on noriellecruz/ontapo
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ontapo-0.1.0.tar.gz -
Subject digest:
077d36d8f3d05992c93617addcac01d6ebe5a18eb0a0c16cbd4033bbd8905d63 - Sigstore transparency entry: 2618936077
- Sigstore integration time:
-
Permalink:
noriellecruz/ontapo@bfcf5a4abbf9014dcd59c761f9da7cb5a3bc6e25 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/noriellecruz
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@bfcf5a4abbf9014dcd59c761f9da7cb5a3bc6e25 -
Trigger Event:
release
-
Statement type:
File details
Details for the file ontapo-0.1.0-py3-none-any.whl.
File metadata
- Download URL: ontapo-0.1.0-py3-none-any.whl
- Upload date:
- Size: 47.9 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
89ce2450ed0c50c6ae021c8936f85b7477a5bc2556a840640cfc389e2889c8be
|
|
| MD5 |
3c0100d2b1e90aad3de626447c34098c
|
|
| BLAKE2b-256 |
48a16f6afea8621e22c17715f25a1a7470ff414fb9a00736f7a8b62f4b9cac6b
|
Provenance
The following attestation bundles were made for ontapo-0.1.0-py3-none-any.whl:
Publisher:
release.yml on noriellecruz/ontapo
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
ontapo-0.1.0-py3-none-any.whl -
Subject digest:
89ce2450ed0c50c6ae021c8936f85b7477a5bc2556a840640cfc389e2889c8be - Sigstore transparency entry: 2618936091
- Sigstore integration time:
-
Permalink:
noriellecruz/ontapo@bfcf5a4abbf9014dcd59c761f9da7cb5a3bc6e25 -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/noriellecruz
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@bfcf5a4abbf9014dcd59c761f9da7cb5a3bc6e25 -
Trigger Event:
release
-
Statement type: