python-tapo-local 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,91 @@
1
+ Metadata-Version: 2.5
2
+ Name: python-tapo-local
3
+ Version: 0.1.0
4
+ Summary: Local (and optional cloud) client for TP-Link Tapo cameras, built from reverse engineering the Tapo app
5
+ Project-URL: Homepage, https://github.com/hudsonbrendon/python-tapo-local
6
+ Project-URL: Repository, https://github.com/hudsonbrendon/python-tapo-local
7
+ Project-URL: Issues, https://github.com/hudsonbrendon/python-tapo-local/issues
8
+ Author-email: Hudson Brendon <contato.hudsonbrendon@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: c200,camera,home-assistant,reverse-engineering,tapo,tp-link
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Home Automation
19
+ Requires-Python: >=3.12
20
+ Requires-Dist: aiohttp>=3.9
21
+ Requires-Dist: pytapo>=3.4.30
22
+ Description-Content-Type: text/markdown
23
+
24
+ # python-tapo-local
25
+
26
+ Client for **TP-Link Tapo cameras** built from reverse engineering the official Tapo
27
+ Android app (3.22.304). It talks to the camera on the local network using the same
28
+ methods and payloads as the app, and it can optionally use the TP-Link cloud (app API
29
+ v2) for push notifications.
30
+
31
+ It powers the [Tapo Local](https://github.com/hudsonbrendon/ha-tapo-local) Home Assistant integration.
32
+
33
+ ```sh
34
+ pip install python-tapo-local
35
+ ```
36
+
37
+ ## Local
38
+
39
+ ```python
40
+ from tapolocal import TapoLocal
41
+
42
+ cam = TapoLocal("192.168.1.50", "<TP-Link account password>") # login: admin + cloud password
43
+ info = cam.device_info() # model, firmware, MAC, alias
44
+ data = cam.fetch() # ~44 getters + 24h detection history + today's recordings
45
+ cam.call("setLedStatus", {"led": {"config": {"enabled": "off"}}})
46
+ ```
47
+
48
+ - Transport comes from [pytapo](https://github.com/JurajNyiri/pytapo) (`securePassthrough`).
49
+ On top of it, this library adds the app's method catalog and request shapes.
50
+ - Requests are batched through `multipleRequest`. Methods the firmware doesn't support
51
+ (`-40209`) are skipped.
52
+ - Login is attempted **at most twice** per call. Every wrong password counts toward the
53
+ camera's 30‑minute lockout, so `TapoAuthError` is raised and it never loops. Errors that
54
+ carry a camera error code (for example `-40214` when a C200 v3 is busy) keep the session
55
+ alive instead of logging in again.
56
+ - Writes use `set<X> {module: {section: {field: value}}}`, the same shape as the matching
57
+ getter. Some C200 quirks: `setDayNightModeConfig` requires `"chn_id": ["1"]`,
58
+ `setFirmwareAutoUpgradeConfig` requires `time` and `random_range`, brightness is only
59
+ accepted through `setImageLumaConfig`, and the siren only takes the legacy
60
+ `{"method": "do", "msg_alarm": {"manual_msg_alarm": …}}` (see `TapoLocal.legacy`).
61
+ - `tapolocal.EVENT_TYPES` maps every `alarm_type` from the app's `PlayBackEventType` enum.
62
+
63
+ ## Cloud (optional)
64
+
65
+ ```python
66
+ import aiohttp
67
+ from tapolocal import TapoCloud, TapoMFARequired
68
+
69
+ async with aiohttp.ClientSession() as s:
70
+ cloud = TapoCloud(s, "you@example.com", "password")
71
+ try:
72
+ await cloud.login()
73
+ except TapoMFARequired as mfa:
74
+ await cloud.send_mfa_email()
75
+ await cloud.check_mfa(mfa.process_id, input("code: "))
76
+ cameras = await cloud.devices()
77
+ notes = await cloud.notifications(hours=24)
78
+ ```
79
+
80
+ - Requests are signed exactly like the app's `m7/r` interceptor:
81
+ `X-Authorization: Timestamp=9999999999, Nonce=…, AccessKey=…, Signature=HMAC-SHA1(...)`.
82
+ - `getAppNotificationByPage` only returns data with `direction: "asc"` starting at
83
+ `indexTime`. `getDeviceMsgsByPage` exists in the API, but the app never calls it and it
84
+ always comes back empty.
85
+ - TLS is verified against the two TP-Link root CAs that the app itself bundles.
86
+
87
+ ## Development
88
+
89
+ ```sh
90
+ uv run --with pytest --with pytest-asyncio --with-editable . pytest
91
+ ```
@@ -0,0 +1,8 @@
1
+ tapolocal/__init__.py,sha256=p4QOPcM8DCmq00X3X5EQGPzWaXlT7HFuQuxAIwghy6Y,527
2
+ tapolocal/api.py,sha256=jJq0IYQrQORupP_MwFn86JAeqkwT4Xi-8EtIwSuNVf4,16086
3
+ tapolocal/events.py,sha256=mZOpJLI08x3x2QVCbSipNog99eUd44TowJEXLwJP_3c,914
4
+ tapolocal/tplink_cloud_ca.pem,sha256=ahcvW9GkIdzu7WIPUCdbWQMHXaypEiECxjKnmYh1yuo,1921
5
+ python_tapo_local-0.1.0.dist-info/METADATA,sha256=LhTrC6rerKPkNGZ6gCu6FuzujaIw9ddVzaxHi-drJm4,3936
6
+ python_tapo_local-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
7
+ python_tapo_local-0.1.0.dist-info/licenses/LICENSE,sha256=4Zgd3fbLJqWSkwKA-hh1rNN6DC9tNQ3rWLX93fingOw,1071
8
+ python_tapo_local-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Hudson Brendon
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
tapolocal/__init__.py ADDED
@@ -0,0 +1,20 @@
1
+ """Clientes para câmeras TP-Link Tapo, a partir da engenharia reversa do app Tapo."""
2
+
3
+ from .api import (
4
+ ACCESS_KEY,
5
+ LOCAL_QUERIES,
6
+ TapoAuthError,
7
+ TapoCloud,
8
+ TapoCloudError,
9
+ TapoCommandError,
10
+ TapoLocal,
11
+ TapoMFARequired,
12
+ sign,
13
+ )
14
+ from .events import EVENT_TYPES, RECORD_TYPES
15
+
16
+ __all__ = [
17
+ "ACCESS_KEY", "EVENT_TYPES", "LOCAL_QUERIES", "RECORD_TYPES", "TapoAuthError", "TapoCloud",
18
+ "TapoCloudError", "TapoCommandError", "TapoLocal", "TapoMFARequired", "sign",
19
+ ]
20
+ __version__ = "0.1.0"
tapolocal/api.py ADDED
@@ -0,0 +1,354 @@
1
+ """Tapo camera clients: local (via pytapo transport) and TP-Link cloud (app API v2).
2
+
3
+ Tudo aqui saiu da engenharia reversa do app Tapo 3.22.304 (com.tplink.iot):
4
+ - métodos/módulos/seções locais: libtapocameranetwork/model/{Method,Module,Section}
5
+ - nuvem: com/tplink/cloud/api/*V2Api + interceptors m7/r (assinatura) e m7/q (query)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import hashlib
12
+ import hmac
13
+ import json
14
+ import ssl
15
+ import threading
16
+ import time
17
+ import uuid
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import aiohttp
22
+
23
+ # ---------------------------------------------------------------- local
24
+
25
+ # (método, params) — mesmos formatos que o app monta em mv0/zk.java.
26
+ # Câmeras que não suportam um método respondem -40209 e ele só fica de fora.
27
+ LOCAL_QUERIES: list[tuple[str, dict]] = [
28
+ ("getDeviceInfo", {"device_info": {"name": ["basic_info"]}}),
29
+ ("getClockStatus", {"system": {"name": ["clock_status"]}}),
30
+ ("getTimezone", {"system": {"name": ["basic"]}}),
31
+ ("getLastAlarmInfo", {"system": {"name": ["last_alarm_info"]}}),
32
+ ("getUserID", {"system": {"get_user_id": {}}}),
33
+ ("getConnectionType", {"network": {"get_connection_type": {}}}),
34
+ ("getDeviceIpAddress", {"network": {"name": ["wan"]}}),
35
+ ("getVideoQualities", {"video": {"name": ["main"]}}),
36
+ ("getVideoCapability", {"video_capability": {"name": ["main", "minor"]}}),
37
+ ("getAudioConfig", {"audio_config": {"name": ["speaker", "microphone", "record_audio"]}}),
38
+ ("getRecordPlan", {"record_plan": {"name": ["chn1_channel"]}}),
39
+ ("getCircularRecordingConfig", {"harddisk_manage": {"name": ["harddisk"]}}),
40
+ ("getSdCardStatus", {"harddisk_manage": {"table": ["hd_info"]}}),
41
+ ("getFirmwareUpdateStatus", {"cloud_config": {"name": ["upgrade_status"]}}),
42
+ ("getFirmwareAutoUpgradeConfig", {"auto_upgrade": {"name": ["common"]}}),
43
+ ("getCloudConfig", {"cloud_config": {"name": ["upgrade_info"]}}),
44
+ ("getMsgPushEventList", {"msg_push": {"table": ["msg_push_event"]}}),
45
+ ("getAlertEventType", {"msg_alarm": {"table": ["msg_alarm_type"]}}),
46
+ ("getDetectionConfig", {"motion_detection": {"name": ["motion_det"]}}),
47
+ ("getPersonDetectionConfig", {"people_detection": {"name": ["detection"]}}),
48
+ ("getUpnpInfo", {"upnpc": {"name": ["upnpc_info"]}}),
49
+ ("getOnvifSecurityConfig", {"security_config": {"onvif_security_config": {}}}),
50
+ ("getThirdPartyCompatibility", {"local_ctrl": {"name": ["third_party_setting"]}}),
51
+ ("getTapoCareServiceList", {"tapo_care": {"name": ["service_list"]}}),
52
+ ("getLensMaskConfig", {"lens_mask": {"name": ["lens_mask_info"]}}),
53
+ ("getLedStatus", {"led": {"name": ["config"]}}),
54
+ ("getRotationStatus", {"image": {"name": ["switch"]}}),
55
+ ("getDayNightModeConfig", {"image": {"name": ["common"]}}),
56
+ ("getTargetTrackConfig", {"target_track": {"name": ["target_track_info"]}}),
57
+ ("getSmartTrackConfig", {"smart_track": {"name": ["smart_track_info"]}}),
58
+ ("getSmartTrackCapability", {"smart_track": {"name": ["smart_track_capability"]}}),
59
+ ("getMsgPushConfig", {"msg_push": {"name": ["chn1_msg_push_info"]}}),
60
+ ("getAlertConfig", {"msg_alarm": {"name": ["chn1_msg_alarm_info"]}}),
61
+ ("getAlertTypeList", {"msg_alarm": {"name": ["alert_type"]}}),
62
+ ("getTamperDetectionConfig", {"tamper_detection": {"name": ["tamper_det"]}}),
63
+ ("getBCDConfig", {"sound_detection": {"name": ["bcd"]}}),
64
+ ("getVehicleDetectionConfig", {"vehicle_detection": {"name": ["detection"]}}),
65
+ ("getPetDetectionConfig", {"pet_detection": {"name": ["detection"]}}),
66
+ ("getCoverConfig", {"cover": {"name": ["cover"]}}),
67
+ ("getOsd", {"OSD": {"name": ["date"]}}),
68
+ ("getPresetConfig", {"preset": {"name": ["preset"]}}),
69
+ ("getPatrolStatus", {"patrol": {"get_patrol_status": {}}}),
70
+ ("getNightVisionCapability", {"image_capability": {"name": ["supplement_lamp"]}}),
71
+ ]
72
+ # Polling rápido (5 s) para movimento/pessoa "ao vivo"; o tapo_control usa ONVIF pra isso.
73
+ LIVE_QUERY = ("getLastAlarmInfo", {"system": {"name": ["last_alarm_info"]}})
74
+ BATCH = 12 # o firmware aceita lotes desse tamanho sem reclamar (testado na C200)
75
+
76
+
77
+ class TapoAuthError(Exception):
78
+ """Senha errada ou câmera em suspensão temporária (lockout de 30 min)."""
79
+
80
+
81
+ class TapoCommandError(Exception):
82
+ def __init__(self, method: str, code) -> None:
83
+ super().__init__(f"{method} falhou: {code}")
84
+ self.code = code
85
+
86
+
87
+ class TapoLocal:
88
+ """Lê a câmera pelo mesmo protocolo do app, via transporte do pytapo.
89
+
90
+ Login = usuário `admin` + senha da conta TP-Link (igual ao app/tapo_control).
91
+ No máximo 1 nova tentativa de login: cada senha errada conta pro lockout
92
+ de 30 min da câmera, mas a C200 fw 1.9.1 às vezes recusa um login logo
93
+ depois de outro (ex.: tapo_control entrando no mesmo segundo).
94
+ """
95
+
96
+ def __init__(self, host: str, password: str) -> None:
97
+ self.host = host
98
+ self._password = password
99
+ self._tapo = None
100
+ self._user_id: int | None = None
101
+ self._lock = threading.Lock() # pytapo guarda seq/nonce; 1 requisição por vez
102
+
103
+ def _client(self):
104
+ if self._tapo is None:
105
+ from pytapo import Tapo # import tardio: pytapo é pesado e síncrono
106
+
107
+ try:
108
+ self._tapo = Tapo(
109
+ self.host, "admin", self._password, self._password, retryStok=False
110
+ )
111
+ except Exception as err: # pytapo só levanta Exception genérica
112
+ if _is_auth(err):
113
+ raise TapoAuthError(str(err)) from err
114
+ raise
115
+ return self._tapo
116
+
117
+ def _multi(self, queries: list[tuple[str, dict]]) -> dict[str, Any]:
118
+ try:
119
+ return self._multi_once(queries)
120
+ except TapoAuthError:
121
+ time.sleep(3)
122
+ return self._multi_once(queries)
123
+
124
+ def _multi_once(self, queries: list[tuple[str, dict]]) -> dict[str, Any]:
125
+ with self._lock:
126
+ return self._multi_locked(queries)
127
+
128
+ def _multi_locked(self, queries: list[tuple[str, dict]]) -> dict[str, Any]:
129
+ out: dict[str, Any] = {}
130
+ for i in range(0, len(queries), BATCH):
131
+ chunk = queries[i : i + BATCH]
132
+ try:
133
+ resp = self._client().performRequest(
134
+ {
135
+ "method": "multipleRequest",
136
+ "params": {"requests": [{"method": m, "params": p} for m, p in chunk]},
137
+ }
138
+ )
139
+ except Exception as err:
140
+ # Erro com código da câmera (ex.: -40214, a C200 v3 ocupada) mantém a sessão;
141
+ # só auth/rede pedem login novo — relogar à toa empurra pro lockout de 30 min.
142
+ if _is_auth(err) or "Response:" not in str(err):
143
+ self._tapo = None
144
+ if _is_auth(err):
145
+ raise TapoAuthError(str(err)) from err
146
+ raise
147
+ for r in resp["result"]["responses"]:
148
+ if r.get("error_code") == 0:
149
+ out[r["method"]] = r.get("result", {})
150
+ return out
151
+
152
+ def device_info(self) -> dict:
153
+ """basic_info da câmera (modelo, firmware, MAC, alias) — valida login."""
154
+ return self._multi([LOCAL_QUERIES[0]])["getDeviceInfo"]["device_info"]["basic_info"]
155
+
156
+ def call(self, method: str, params: dict) -> dict:
157
+ """Executa um set*/do* e devolve a resposta (levanta se error_code != 0)."""
158
+ with self._lock:
159
+ resp = self._client().performRequest(
160
+ {"method": "multipleRequest", "params": {"requests": [{"method": method, "params": params}]}}
161
+ )["result"]["responses"][0]
162
+ if resp.get("error_code") != 0:
163
+ raise TapoCommandError(method, resp.get("error_code"))
164
+ return resp.get("result", {})
165
+
166
+ def legacy(self, body: dict) -> dict:
167
+ """Formato antigo {"method": "do"/"set", ...} — a sirene da C200 só aceita assim."""
168
+ with self._lock:
169
+ return self._client().performRequest(body)
170
+
171
+ def fetch_live(self) -> dict[str, Any]:
172
+ now = int(time.time())
173
+ return {**self._multi([LIVE_QUERY, (
174
+ "searchDetectionList",
175
+ {"playback": {"search_detection_list": {
176
+ "start_index": 0, "end_index": 50, "channel": 0,
177
+ "start_time": now - 120, "end_time": now + 60,
178
+ }}},
179
+ )]), "_fetched_at": now}
180
+
181
+ def fetch(self) -> dict[str, Any]:
182
+ now = int(time.time())
183
+ data = self._multi(LOCAL_QUERIES)
184
+ if (uid := data.get("getUserID", {}).get("user_id")) is not None:
185
+ self._user_id = uid
186
+ history: list[tuple[str, dict]] = [
187
+ (
188
+ "searchDetectionList",
189
+ {"playback": {"search_detection_list": {
190
+ "start_index": 0, "end_index": 999, "channel": 0,
191
+ "start_time": now - 86400, "end_time": now,
192
+ }}},
193
+ )
194
+ ]
195
+ local_day = _camera_day(data)
196
+ if local_day and self._user_id is not None:
197
+ history.append(
198
+ (
199
+ "searchVideoOfDay",
200
+ {"playback": {"search_video_utility": {
201
+ "channel": 0, "date": local_day, "start_index": 0,
202
+ "end_index": 999, "id": self._user_id,
203
+ }}},
204
+ )
205
+ )
206
+ data.update(self._multi(history))
207
+ data["_fetched_at"] = now
208
+ return data
209
+
210
+
211
+ def _is_auth(err: Exception) -> bool:
212
+ msg = str(err)
213
+ return "Invalid authentication" in msg or "Suspension" in msg
214
+
215
+
216
+ def _camera_day(data: dict) -> str | None:
217
+ local = data.get("getClockStatus", {}).get("system", {}).get("clock_status", {}).get("local_time")
218
+ return local[:10].replace("-", "") if local else None
219
+
220
+
221
+ # ---------------------------------------------------------------- nuvem
222
+
223
+ APP_TYPE = "TP-Link_Tapo_Android"
224
+ APP_VER = "3.22.304"
225
+ ACCESS_KEY = "4d11b6b9d5ea4d19a829adbb9714b057" # bs0/b.g() (prod)
226
+ SECRET_KEY = "6ed7d97f3e73467f8a5bab90b577ba4c" # bs0/b.h() (prod)
227
+ CLOUD_BASE = "https://n-wap.i.tplinkcloud.com" # bs0/b.i() (prod)
228
+
229
+
230
+ class TapoCloudError(Exception):
231
+ def __init__(self, code: int, msg: str) -> None:
232
+ super().__init__(f"{code}: {msg}")
233
+ self.code = code
234
+
235
+
236
+ class TapoMFARequired(Exception):
237
+ def __init__(self, process_id: str) -> None:
238
+ super().__init__("MFA required")
239
+ self.process_id = process_id
240
+
241
+
242
+ def _ssl_context() -> ssl.SSLContext:
243
+ # Mesma raiz que o app embarca em mergedCA.pem; a TP-Link não usa CA pública.
244
+ ctx = ssl.create_default_context(cafile=str(Path(__file__).with_name("tplink_cloud_ca.pem")))
245
+ return ctx
246
+
247
+
248
+ def sign(body: bytes, path: str, nonce: str) -> dict[str, str]:
249
+ """Headers do interceptor m7/r: HMAC-SHA1(SK, md5b64\\n9999999999\\nnonce\\npath)."""
250
+ md5 = base64.b64encode(hashlib.md5(body).digest()).decode()
251
+ payload = f"{md5}\n9999999999\n{nonce}\n{path}".encode()
252
+ sig = hmac.new(SECRET_KEY.encode(), payload, hashlib.sha1).hexdigest()
253
+ return {
254
+ "Content-MD5": md5,
255
+ "X-Authorization": f"Timestamp=9999999999, Nonce={nonce}, AccessKey={ACCESS_KEY}, Signature={sig}",
256
+ }
257
+
258
+
259
+ class TapoCloud:
260
+ def __init__(self, session: aiohttp.ClientSession, username: str, password: str,
261
+ terminal_uuid: str | None = None, token: str | None = None,
262
+ url: str | None = None) -> None:
263
+ self._session = session
264
+ self.username = username
265
+ self._password = password
266
+ self.terminal_uuid = terminal_uuid or uuid.uuid4().hex.upper()
267
+ self.token = token
268
+ self.url = url
269
+ self._ssl = None
270
+
271
+ async def _post(self, base: str, path: str, body: dict, token: bool = True) -> dict:
272
+ if self._ssl is None:
273
+ import asyncio
274
+
275
+ self._ssl = await asyncio.get_running_loop().run_in_executor(None, _ssl_context)
276
+ raw = json.dumps(body, separators=(",", ":")).encode()
277
+ params = {
278
+ "appName": APP_TYPE, "appVer": APP_VER, "netType": "wifi",
279
+ "termID": self.terminal_uuid, "ospf": "Android 14", "brand": "TPLINK",
280
+ "locale": "pt_BR", "termName": "Home Assistant",
281
+ }
282
+ if token and self.token:
283
+ params["token"] = self.token
284
+ headers = {"Content-Type": "application/json;charset=UTF-8", **sign(raw, path, str(uuid.uuid4()))}
285
+ async with self._session.post(base + path, params=params, data=raw, headers=headers,
286
+ ssl=self._ssl, timeout=aiohttp.ClientTimeout(total=30)) as r:
287
+ res = await r.json(content_type=None)
288
+ code = res.get("error_code", -1)
289
+ if code != 0:
290
+ raise TapoCloudError(code, res.get("msg", ""))
291
+ return res.get("result") or {}
292
+
293
+ async def login(self) -> None:
294
+ st = await self._post(CLOUD_BASE, "/api/v2/account/getAccountStatusAndUrl",
295
+ {"appType": APP_TYPE, "cloudUserName": self.username}, token=False)
296
+ self.url = st.get("appServerUrlV2") or st.get("appServerUrl")
297
+ res = await self._post(self.url, "/api/v2/account/login", {
298
+ "appType": APP_TYPE, "appVersion": APP_VER, "cloudUserName": self.username,
299
+ "cloudPassword": self._password, "platform": "Android 14", "refreshTokenNeeded": True,
300
+ "terminalUUID": self.terminal_uuid, "terminalName": "Home Assistant",
301
+ "terminalMeta": "1", "supportBindAccount": False,
302
+ }, token=False)
303
+ if res.get("mfaProcessId") and not res.get("token"):
304
+ raise TapoMFARequired(res["mfaProcessId"])
305
+ self.token = res["token"]
306
+
307
+ async def send_mfa_email(self) -> None:
308
+ await self._post(self.url, "/api/v2/account/getEmailVC4TerminalMFA", {
309
+ "appType": APP_TYPE, "cloudUserName": self.username,
310
+ "cloudPassword": self._password, "terminalUUID": self.terminal_uuid, "locale": "pt_BR",
311
+ }, token=False)
312
+
313
+ async def check_mfa(self, process_id: str, code: str) -> None:
314
+ res = await self._post(self.url, "/api/v2/account/checkMFACodeAndLogin", {
315
+ "appType": APP_TYPE, "cloudUserName": self.username, "code": code,
316
+ "mfaProcessId": process_id, "mfaType": 1, "terminalBindEnabled": True,
317
+ "supportBindAccount": False,
318
+ }, token=False)
319
+ self.token = res["token"]
320
+
321
+ async def _call(self, path: str, body: dict) -> dict:
322
+ try:
323
+ return await self._post(self.url, path, body)
324
+ except TapoCloudError as err:
325
+ if err.code not in (-20651, -20675): # token expirado/inválido
326
+ raise
327
+ await self.login()
328
+ return await self._post(self.url, path, body)
329
+
330
+ async def devices(self) -> list[dict]:
331
+ res = await self._call("/api/v2/common/getDeviceListByPage", {
332
+ "deviceTypeList": ["SMART.IPCAMERA"], "index": 0, "limit": 50})
333
+ return res.get("deviceList", [])
334
+
335
+ async def notifications(self, hours: int = 24) -> list[dict]:
336
+ """Central de notificações do app (NotificationCenterManager.V()).
337
+
338
+ O servidor só responde em ordem ascendente a partir de indexTime (desc vem vazio).
339
+ getDeviceMsgsByPage existe na API mas o app não usa e volta sempre vazio.
340
+ """
341
+ since = int(time.time() * 1000) - hours * 3600_000
342
+ out: list[dict] = []
343
+ index = 0
344
+ for _ in range(20): # 20 páginas × 50 = teto de 1000 notificações no período
345
+ res = await self._call("/api/v2/common/getAppNotificationByPage", {
346
+ "appType": APP_TYPE, "terminalUUID": self.terminal_uuid, "mobileType": "ANDROID",
347
+ "deviceToken": "", "locale": "pt_BR", "direction": "asc", "index": index,
348
+ "indexTime": since, "limit": 50, "contentVersion": 3,
349
+ })
350
+ out += res.get("notifications", [])
351
+ if not res.get("hasNextPage"):
352
+ break
353
+ index = res.get("currentIndex", index) + 1
354
+ return out
tapolocal/events.py ADDED
@@ -0,0 +1,15 @@
1
+ """Códigos de evento/gravação do firmware (enums do app Tapo 3.22.304)."""
2
+
3
+ # PlayBackEventType (libtapocameranetwork/business/model) — alarm_type do searchDetectionList
4
+ EVENT_TYPES = {
5
+ 1: "timing", 2: "motion", 3: "tamper", 4: "linecross", 5: "area_intrusion",
6
+ 6: "person", 7: "baby_cry", 8: "vehicle", 9: "pet", 10: "ring_alarm",
7
+ 11: "bark", 12: "meow", 13: "glass_break", 14: "smoke_alarm",
8
+ 15: "package_delivered", 16: "package_picked_up", 17: "doorbell_missed",
9
+ 18: "doorbell_answered", 19: "anti_theft", 23: "baby_leave", 24: "baby_caregiver",
10
+ 25: "baby_asleep", 26: "baby_awake", 27: "face_cover", 28: "safe_fence_out",
11
+ 30: "baby_motion", 31: "panoramic_video", 33: "animal", 34: "door_unlock",
12
+ 35: "gate_open", 36: "co_alarm",
13
+ }
14
+ # PlayBackVideoType — tipo do trecho no plano de gravação ("0000-2400:2")
15
+ RECORD_TYPES = {"1": "continuous", "2": "events", "3": "aov"}
@@ -0,0 +1,33 @@
1
+ -----BEGIN CERTIFICATE-----
2
+ MIICKTCCAdCgAwIBAgIUXZHoRKIvheKCxJL4s2rZ/alfEGMwCgYIKoZIzj0EAwMw
3
+ cjEeMBwGA1UEAwwVVFAtTGluayBDbG91ZCBSb290IENBMR0wGwYDVQQKDBRUUC1M
4
+ aW5rIFN5c3RlbXMgSW5jLjEPMA0GA1UEBwwGSXJ2aW5lMRMwEQYDVQQIDApDYWxp
5
+ Zm9ybmlhMQswCQYDVQQGEwJVUzAgFw0yNTAyMjUwMzQ3MTFaGA8yMDU1MDIxODAz
6
+ NDcxMVowcjEeMBwGA1UEAwwVVFAtTGluayBDbG91ZCBSb290IENBMR0wGwYDVQQK
7
+ DBRUUC1MaW5rIFN5c3RlbXMgSW5jLjEPMA0GA1UEBwwGSXJ2aW5lMRMwEQYDVQQI
8
+ DApDYWxpZm9ybmlhMQswCQYDVQQGEwJVUzBZMBMGByqGSM49AgEGCCqGSM49AwEH
9
+ A0IABAHpyZzXk1E4VWyIB+9VMci2Sw5OdeQ+kyFc2/R/f/OiHt6hk4PfMcCKKgS6
10
+ hkVVYyVlobJNn052z+s9DJKJgfajQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0P
11
+ AQH/BAQDAgGGMB0GA1UdDgQWBBQLV0PTee1jvnJMcUCEtQgJSg/oyTAKBggqhkjO
12
+ PQQDAwNHADBEAiAURU4PvHXUrVViDF+rCky3RD9BeUpe91kYoNWe0oKVUwIgY0C5
13
+ MsDHbQrwLHs1I7+f2MvZDGBHhNjnl0n4MF9q0L0=
14
+ -----END CERTIFICATE-----
15
+ -----BEGIN CERTIFICATE-----
16
+ MIIDBzCCAe+gAwIBAgIQT5x0ma7QnINHCQvhnmzR9zANBgkqhkiG9w0BAQsFADAV
17
+ MRMwEQYDVQQDEwp0cC1saW5rLUNBMCAXDTE4MDExOTA4Mjc1MloYDzIwNjgwMTE5
18
+ MDgzNzUyWjAVMRMwEQYDVQQDEwp0cC1saW5rLUNBMIIBIjANBgkqhkiG9w0BAQEF
19
+ AAOCAQ8AMIIBCgKCAQEAuGG8n5zEUN1j5wuvUz4pAIMurhKHbpfUUu+b2acFHKS6
20
+ iU9hNJWvDyhXcihY5Wz6aq9m4D5SZcgW3k31YoNNtrztDjdg2qw7AaX85S99/G0B
21
+ VbIXktrhs34OW19WA/haDwut3dFhLem+gCRRKUXcmuqchZc84dY7JFVfhPcJci4m
22
+ sRjLCFNO0ho9OX+MZwfO4BLaeAqKVoAor6rf4BXVtO0xjYHDKO0fb3AWLLJ4EjGe
23
+ q6YieqPiYlPFEqRm5PrvBXTm0IuQogygyVpK4LHr/K207ZLyV33DxLLbsUgSEJVn
24
+ pZUv/WUujXjlIDgxIvyZZCYiXO3dle2/MEvpmZk6JQIDAQABo1EwTzALBgNVHQ8E
25
+ BAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUxu2iBRTsef5iNnsADVhM
26
+ JDQWi6kwEAYJKwYBBAGCNxUBBAMCAQAwDQYJKoZIhvcNAQELBQADggEBAB52Majd
27
+ +wo3cb5BsTo63z2Psbbyl4ACMUaw68NxUMy61Oihx3mcLzLJqiIZcKePiHskLqLJ
28
+ F7QfT9TqjvizMjFJVgsLuVubUBXKBzqyN+3KKlQci0PO3mH+ObhyaE7BzV+qrS3P
29
+ dVTgsCWFv8DkgLTRudSWxL7VwVoedc7lRz5EroGgJ33nRGCR0ngcW919tLTARDQO
30
+ pULmzulcdWeZgG+0PLX0xjJQIjFEvbOxR1Z+gxMupBz0rWFokmWYrcga8eWiWzjQ
31
+ Ia3/ASBVJ69srV77trWlfLumkChbXk9i64NXBKnce0Jmll0Y9OC1nMPqrbQKnzcn
32
+ dSAA4fejD/qMQn0=
33
+ -----END CERTIFICATE-----