Skip to content

Commit

Permalink
Support C420 camera recording download via H200.
Browse files Browse the repository at this point in the history
  • Loading branch information
Ryuya Matsumoto committed Jun 2, 2023
1 parent 920cd6e commit e144ae8
Show file tree
Hide file tree
Showing 9 changed files with 248 additions and 11 deletions.
2 changes: 2 additions & 0 deletions configs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/*.json
!/example.json.sample
11 changes: 11 additions & 0 deletions configs/example.json.sample
Original file line number Diff line number Diff line change
@@ -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
}
119 changes: 119 additions & 0 deletions experiments/DownloadRecordingsViaHub.py
Original file line number Diff line number Diff line change
@@ -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)
80 changes: 76 additions & 4 deletions pytapo/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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")
Expand All @@ -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",
{
Expand All @@ -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)
Expand Down
19 changes: 19 additions & 0 deletions pytapo/media_stream/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
9 changes: 5 additions & 4 deletions pytapo/media_stream/downloader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion pytapo/media_stream/pes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 12 additions & 1 deletion pytapo/media_stream/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 (
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -75,14 +83,16 @@ 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()
).encode(),
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
Expand Down Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down

0 comments on commit e144ae8

Please sign in to comment.