Metadata-Version: 2.5
Name: ontapo
Version: 0.1.0
Summary: Educational async Python client for TP-Link Tapo cameras over the cloud account — control, live view and SD-card recordings without LAN access. Unofficial, unaffiliated, provided as-is with no warranty.
Project-URL: Homepage, https://github.com/noriellecruz/ontapo
Project-URL: Issues, https://github.com/noriellecruz/ontapo/issues
Project-URL: Disclaimer, https://github.com/noriellecruz/ontapo/blob/main/DISCLAIMER.md
Project-URL: Changelog, https://github.com/noriellecruz/ontapo/blob/main/CHANGELOG.md
Author: Norielle Cruz
License: MIT
License-File: LICENSE
Keywords: camera,cctv,cloud,educational,ip-camera,reverse-engineering,tapo,tplink
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Education
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Education
Classifier: Topic :: Home Automation
Classifier: Topic :: Multimedia :: Video
Classifier: Topic :: Security
Requires-Python: >=3.11
Requires-Dist: httpx>=0.27
Provides-Extra: dev
Requires-Dist: mypy>=1.11; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
Requires-Dist: pytest>=8; extra == 'dev'
Requires-Dist: respx>=0.21; extra == 'dev'
Requires-Dist: ruff>=0.6; extra == 'dev'
Description-Content-Type: text/markdown

# 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/`](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](DISCLAIMER.md) and [LICENSE](LICENSE).

## Install

```bash
pip install ontapo
```

## A complete example

[`examples/backup.py`](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

```python
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)

```python
# 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)

```python
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

```python
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:

```python
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:

```python
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

```python
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:

```python
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):

```python
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**.

```python
# 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:

```python
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](CONTRIBUTING.md).

## Design

- **Async** (`asyncio` + `httpx`) — concurrent multi-camera work and future live streaming.
- **One session per task** — `OnTapo` 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

```bash
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:

```bash
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):

```bash
python scripts/check_no_secrets.py
```

Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).

## Disclaimer & license

MIT © Norielle Cruz — see [LICENSE](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](DISCLAIMER.md).
