Skip to main content

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 -40101 for line-crossing and intrusion detection because the hardware lacks them; that surfaces as UnsupportedMethod.
  • 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. Orientation reports 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 -52405 until 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, and download() 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 taskOnTapo is 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

ontapo-0.1.0.tar.gz (78.9 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ontapo-0.1.0-py3-none-any.whl (47.9 kB view details)

Uploaded Python 3

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

Hashes for ontapo-0.1.0.tar.gz
Algorithm Hash digest
SHA256 077d36d8f3d05992c93617addcac01d6ebe5a18eb0a0c16cbd4033bbd8905d63
MD5 204785bb86f3680cbed7fcbc867ab265
BLAKE2b-256 aa3eaa6d215aedc8163837d4e3fa581fdc2ddbbbde1325675b67bf7931f8ff37

See more details on using hashes here.

Provenance

The following attestation bundles were made for ontapo-0.1.0.tar.gz:

Publisher: release.yml on noriellecruz/ontapo

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

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

Hashes for ontapo-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 89ce2450ed0c50c6ae021c8936f85b7477a5bc2556a840640cfc389e2889c8be
MD5 3c0100d2b1e90aad3de626447c34098c
BLAKE2b-256 48a16f6afea8621e22c17715f25a1a7470ff414fb9a00736f7a8b62f4b9cac6b

See more details on using hashes here.

Provenance

The following attestation bundles were made for ontapo-0.1.0-py3-none-any.whl:

Publisher: release.yml on noriellecruz/ontapo

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page