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.
- python_tapo_local-0.1.0.dist-info/METADATA +91 -0
- python_tapo_local-0.1.0.dist-info/RECORD +8 -0
- python_tapo_local-0.1.0.dist-info/WHEEL +4 -0
- python_tapo_local-0.1.0.dist-info/licenses/LICENSE +21 -0
- tapolocal/__init__.py +20 -0
- tapolocal/api.py +354 -0
- tapolocal/events.py +15 -0
- tapolocal/tplink_cloud_ca.pem +33 -0
|
@@ -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,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-----
|