diff --git a/configs/.gitignore b/configs/.gitignore new file mode 100644 index 0000000..00fb95f --- /dev/null +++ b/configs/.gitignore @@ -0,0 +1,2 @@ +/*.json +!/example.json.sample \ No newline at end of file diff --git a/configs/example.json.sample b/configs/example.json.sample new file mode 100644 index 0000000..dbdf9ed --- /dev/null +++ b/configs/example.json.sample @@ -0,0 +1,11 @@ +{ + "outputRootDir": "./output", + "type": "TAPO.HUB", + "host":"172.0.0.2", + "user": "admin", + "password": "password", + "cloudPassword": "password", + "superSecretKey": "", + "playerID": "145BDEA3-B18C-288D-61AD-342C312E9FEA", + "windowSize": 50 +} \ No newline at end of file diff --git a/experiments/DownloadRecordingsViaHub.py b/experiments/DownloadRecordingsViaHub.py new file mode 100644 index 0000000..1c9a5c7 --- /dev/null +++ b/experiments/DownloadRecordingsViaHub.py @@ -0,0 +1,119 @@ +from pytapo import Tapo +from pytapo.media_stream.downloader import Downloader +import asyncio +import os +from datetime import datetime +import json +from glob import glob +from dataclasses import dataclass +from dataclasses_json import dataclass_json + +# Directory where the Json config is stored +CONFIG_DIR = "./configs/" + + +@dataclass_json +@dataclass +class TapoConfig: + outputRootDir: str = "./output" + deviceType: str = "TAPO.HUB" + host: str = "" + user: str = "admin" + password: str = "" + cloudPassword: str = "" + superSecretKey: str = "" + playerID: str = "" + windowSize: int = 50 + + def setOutputDirectory(self, alias_name): + self.outputDir = os.path.join( + self.outputRootDir, + alias_name, + ) + return self.outputDir + + +async def download_async_by_date(tapo_camera: Tapo, date: str, config: TapoConfig): + # Get list to download + recordings = tapo_camera.getRecordings(date) + for recording in recordings: + for key in recording: + downloader = Downloader( + tapo_camera, + recording[key]["startTime"], + recording[key]["endTime"], + config.outputDir, + None, + False, + config.windowSize, + fileName=f"{datetime.fromtimestamp(int(recording[key]['startTime'])).strftime('%Y-%m-%d %H_%M_%S')}.mp4", + ) + async for status in downloader.download(): + statusString = status["currentAction"] + " " + status["fileName"] + if status["progress"] > 0: + statusString += ( + ": " + + str(round(status["progress"], 2)) + + " / " + + str(status["total"]) + ) + else: + statusString += "..." + print( + statusString + (" " * 10) + "\r", + end="", + ) + print("") + + +async def download_async(tapo_camera: Tapo, config: TapoConfig): + print("Getting recordings...") + recordings_date = tapo_camera.getRecordingsList() + + recordings_date_list = [ + v["date"] + for search_results in recordings_date + for _, v in search_results.items() + ] + + for date in recordings_date_list: + await download_async_by_date(tapo_camera, date, config) + + +def exec_download(config: TapoConfig): + # Connecting H200 Hub + print("Connecting to Hub...") + tapo_hub = Tapo( + config.host, config.user, config.cloudPassword, config.cloudPassword + ) + + # Get Child Camera Devices + for d in tapo_hub.getChildDevices(): + if not d.get("device_model") in ["C400", "C420"]: + print(f"{d.get('device_model')} is not supported.") + continue + child_device_id = d["device_id"] + device_alias = d["alias"] + + print(f"{device_alias} : {child_device_id}") + tapo_camera = Tapo( + config.host, + config.user, + config.cloudPassword, + config.cloudPassword, + childID=child_device_id, + playerID=config.playerID, + ) + output_dir = config.setOutputDirectory(device_alias) + os.makedirs(output_dir, exist_ok=True) + + loop = asyncio.get_event_loop() + loop.run_until_complete(download_async(tapo_camera, config)) + + +if __name__ == "__main__": + config_files = glob(os.path.join(CONFIG_DIR, "*.json")) + for config_file in config_files: + with open(config_file) as f: + config = TapoConfig.from_dict(json.load(f)) + exec_download(config) diff --git a/pytapo/__init__.py b/pytapo/__init__.py index 1ac9d3e..c4a83c3 100644 --- a/pytapo/__init__.py +++ b/pytapo/__init__.py @@ -12,14 +12,16 @@ from .const import ERROR_CODES, MAX_LOGIN_RETRIES from .media_stream.session import HttpMediaSession -from datetime import datetime +from datetime import datetime, timedelta +from retry import retry + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) class Tapo: def __init__( - self, host, user, password, cloudPassword="", superSecretKey="", childID=None + self, host, user, password, cloudPassword="", superSecretKey="", childID=None, playerID=None, ): self.host = host self.user = user @@ -29,6 +31,7 @@ def __init__( self.stok = False self.userID = False self.childID = childID + self.playerID = playerID self.timeCorrection = False self.headers = { "Host": self.host, @@ -204,9 +207,22 @@ def performRequest(self, requestData, loginRetryCount=0): elif self.responseIsOK(res): return responseJSON - def getMediaSession(self): + def getMediaSession(self, start_time=""): + basicInfo = self.basicInfo["device_info"]["basic_info"] + if basicInfo["device_model"] in ["C400", "C420"]: + query_params = { + "deviceId": basicInfo["dev_id"], + "playerId": self.playerID, + "type": "sdvod", + "start_time": start_time, + } + else: + query_params = {} return HttpMediaSession( - self.host, self.cloudPassword, self.superSecretKey + self.host, + self.cloudPassword, + self.superSecretKey, + query_params=query_params, ) # pragma: no cover def getChildDevices(self): @@ -563,6 +579,7 @@ def getUserID(self): )["result"]["responses"][0]["result"]["user_id"] return self.userID + @retry(tries=3) def getRecordingsList(self, start_date="20000101", end_date=None): if end_date is None: end_date = datetime.today().strftime("%Y%m%d") @@ -583,6 +600,16 @@ def getRecordingsList(self, start_date="20000101", end_date=None): return result["playback"]["search_results"] def getRecordings(self, date, start_index=0, end_index=999999999): + if self.basicInfo.get("device_info").get("basic_info").get("device_model") in [ + "C400", + "C420", + ]: + date_object = datetime.strptime(date, "%Y%m%d") + start_time = int(date_object.timestamp()) + end_time = int( + (date_object + timedelta(hours=23, minutes=59, seconds=59)).timestamp() + ) + return self.getRecordingsUTC(start_time, end_time, start_index, end_index) result = self.executeFunction( "searchVideoOfDay", { @@ -601,6 +628,51 @@ def getRecordings(self, date, start_index=0, end_index=999999999): raise Exception("Video playback is not supported by this camera") return result["playback"]["search_video_results"] + def searchDetectionList( + self, start_time, end_time, start_index=0, end_index=999999999 + ): + # h200 supported API + result = self.executeFunction( + "searchDetectionList", + { + "playback": { + "search_detection_list": { + "channel": 0, + "end_index": end_index, + "end_time": end_time, + "start_index": start_index, + "start_time": start_time, + } + } + }, + ) + if "playback" not in result: + raise Exception("Video playback is not supported by this camera") + return result["playback"]["search_video_results"] + + def getRecordingsUTC( + self, start_time, end_time, start_index=0, end_index=999999999 + ): + # h200 supported API + result = self.executeFunction( + "searchVideoWithUTC", + { + "playback": { + "search_video_with_utc": { + "channel": 0, + "end_time": end_time , + "end_index": end_index, + "id": self.getUserID(), + "start_index": start_index, + "start_time":start_time, + } + } + }, + ) + if "playback" not in result: + raise Exception("Video playback is not supported by this camera") + return result["playback"]["search_video_results"] + # does not work for child devices, function discovery needed def getCommonImage(self): warn("Prefer to use a specific value getter", DeprecationWarning, stacklevel=2) diff --git a/pytapo/media_stream/_utils.py b/pytapo/media_stream/_utils.py index c836ae9..4544309 100644 --- a/pytapo/media_stream/_utils.py +++ b/pytapo/media_stream/_utils.py @@ -4,6 +4,25 @@ from typing import Mapping, Tuple, Optional +def check_and_currect_http_response(data: bytes) -> bytes: + __HTTP_VERSION_LIST = [ + "HTTP/0.9", + "HTTP/1.0", + "HTTP/1.1", + "HTTP/2", + "HTTP/3", + ] + decode_data = data.decode() + check = any([decode_data.startswith(v) for v in __HTTP_VERSION_LIST]) + if not check: + for v in __HTTP_VERSION_LIST: + pos = decode_data.find(v) + if pos != -1: + return decode_data[pos:].encode() + else: + return data + + def md5digest(to_hash: bytes) -> bytes: return hashlib.md5(to_hash).digest().hex().upper().encode() diff --git a/pytapo/media_stream/downloader.py b/pytapo/media_stream/downloader.py index be88524..8d4c643 100644 --- a/pytapo/media_stream/downloader.py +++ b/pytapo/media_stream/downloader.py @@ -78,11 +78,12 @@ async def download(self, retry=False): ) segmentLength = self.endTime - self.startTime if self.fileName is None: - fileName = ( - self.outputDirectory + str(dateStart) + "-" + dateEnd + ".mp4" + fileName = os.path.join( + self.outputDirectory, + f"{dateStart}-{dateEnd}.mp4", ) else: - fileName = self.outputDirectory + self.fileName + fileName = os.path.join(self.outputDirectory, self.fileName) if self.scriptStartTime - self.FRESH_RECORDING_TIME_SECONDS < self.endTime: currentAction = "Recording in progress" yield { @@ -103,7 +104,7 @@ async def download(self, retry=False): downloading = False else: convert = Convert() - mediaSession = self.tapo.getMediaSession() + mediaSession = self.tapo.getMediaSession(str(self.startTime)) if retry: mediaSession.set_window_size(50) else: diff --git a/pytapo/media_stream/pes.py b/pytapo/media_stream/pes.py index e68d777..bf85b72 100644 --- a/pytapo/media_stream/pes.py +++ b/pytapo/media_stream/pes.py @@ -66,7 +66,9 @@ def GetPacket(self) -> RTP: hasPTS = 0b1000_0000 if flags & hasPTS: ts = parse_time(self.Payload[self.minHeaderSize :]) - + # Retrieval via hub does not get timestamp. + if (type(ts) != int) or ((ts < 0) or (ts >= 2**32)): + ts = 0 streamType = None for var_name, var_value in vars(PayloadType).items(): if var_value == self.StreamType: diff --git a/pytapo/media_stream/session.py b/pytapo/media_stream/session.py index 128766b..5d2d8a9 100644 --- a/pytapo/media_stream/session.py +++ b/pytapo/media_stream/session.py @@ -7,6 +7,7 @@ from asyncio import StreamReader, StreamWriter, Task, Queue from json import JSONDecodeError from typing import Optional, Mapping, Generator, MutableMapping +import urllib.parse from rtp import PayloadType @@ -15,6 +16,7 @@ md5digest, parse_http_response, parse_http_headers, + check_and_currect_http_response, ) from pytapo.media_stream.crypto import AESHelper from pytapo.media_stream.error import ( @@ -37,6 +39,7 @@ def __init__( port: int = 8800, username: str = "admin", multipart_boundary: bytes = b"--client-stream-boundary--", + query_params: dict = {}, ): self.ip = ip self.window_size = window_size @@ -47,6 +50,11 @@ def __init__( self.username = username self.client_boundary = multipart_boundary + self.query_params = query_params + self.query_params_str = "" + if any(query_params): + self.query_params_str = f"?{urllib.parse.urlencode(query_params)}" + self._started: bool = False self._response_handler_task: Optional[Task] = None @@ -75,7 +83,7 @@ async def __aenter__(self): return self async def start(self): - req_line = b"POST /stream HTTP/1.1" + req_line = f"POST /stream{self.query_params_str} HTTP/1.1".encode() headers = { b"Content-Type": "multipart/mixed;boundary={}".format( self.client_boundary.decode() @@ -83,6 +91,8 @@ async def start(self): b"Connection": b"keep-alive", b"Content-Length": b"-1", } + if self.query_params_str: + headers[b"X-Client-UUID"] = self.query_params["playerId"].encode() try: self._reader, self._writer = await asyncio.open_connection( self.ip, self.port @@ -150,6 +160,7 @@ async def start(self): # Ensure the request was successful data = await self._reader.readuntil(b"\r\n\r\n") + data = check_and_currect_http_response(data) res_line, headers_block = data.split(b"\r\n", 1) _, status_code, _ = parse_http_response(res_line) if status_code != 200: diff --git a/setup.py b/setup.py index 1b31201..624043d 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,7 @@ long_description_content_type="text/markdown", url="https://github.com/JurajNyiri/pytapo", packages=setuptools.find_packages(), - install_requires=["requests", "urllib3", "pycryptodome", "rtp"], + install_requires=["requests", "urllib3", "pycryptodome", "rtp", "dataclasses_json"], tests_require=["pytest", "pytest-asyncio", "mock"], classifiers=[ "Programming Language :: Python :: 3",