updates
This commit is contained in:
9
deps/lib/python3.10/site-packages/pytapo/ERROR_CODES.py
vendored
Normal file
9
deps/lib/python3.10/site-packages/pytapo/ERROR_CODES.py
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
ERROR_CODES = {
|
||||
"-40401": "Invalid stok value",
|
||||
"-64324": "Privacy mode is ON, not able to execute",
|
||||
"-64302": "Preset ID not found",
|
||||
"-64321": "Preset ID was deleted so no longer exists",
|
||||
"-40106": "Parameter to get/do does not exist",
|
||||
"-40105": "Method does not exist",
|
||||
"-40101": "Parameter to set does not exist"
|
||||
}
|
872
deps/lib/python3.10/site-packages/pytapo/__init__.py
vendored
Normal file
872
deps/lib/python3.10/site-packages/pytapo/__init__.py
vendored
Normal file
@ -0,0 +1,872 @@
|
||||
#
|
||||
# Author: See contributors at https://github.com/JurajNyiri/pytapo/graphs/contributors
|
||||
#
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
from warnings import warn
|
||||
|
||||
from .const import ERROR_CODES, MAX_LOGIN_RETRIES
|
||||
from .media_stream.session import HttpMediaSession
|
||||
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
|
||||
class Tapo:
|
||||
def __init__(
|
||||
self, host, user, password, cloudPassword="", superSecretKey="", childID=None
|
||||
):
|
||||
self.host = host
|
||||
self.user = user
|
||||
self.password = password
|
||||
self.cloudPassword = cloudPassword
|
||||
self.superSecretKey = superSecretKey
|
||||
self.stok = False
|
||||
self.userID = False
|
||||
self.childID = childID
|
||||
self.headers = {
|
||||
"Host": self.host,
|
||||
"Referer": "https://{host}".format(host=self.host),
|
||||
"Accept": "application/json",
|
||||
"Accept-Encoding": "gzip, deflate",
|
||||
"User-Agent": "Tapo CameraClient Android",
|
||||
"Connection": "close",
|
||||
"requestByApp": "true",
|
||||
"Content-Type": "application/json; charset=UTF-8",
|
||||
}
|
||||
self.hashedPassword = hashlib.md5(password.encode("utf8")).hexdigest().upper()
|
||||
self.hashedCloudPassword = (
|
||||
hashlib.md5(cloudPassword.encode("utf8")).hexdigest().upper()
|
||||
)
|
||||
|
||||
self.basicInfo = self.getBasicInfo()
|
||||
self.presets = self.isSupportingPresets()
|
||||
if not self.presets:
|
||||
self.presets = {}
|
||||
|
||||
def isSupportingPresets(self):
|
||||
try:
|
||||
presets = self.getPresets()
|
||||
return presets
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def getHostURL(self):
|
||||
return "https://{host}/stok={stok}/ds".format(host=self.host, stok=self.stok)
|
||||
|
||||
def getStreamURL(self):
|
||||
return "{host}:8800".format(host=self.host)
|
||||
|
||||
def ensureAuthenticated(self):
|
||||
if not self.stok:
|
||||
return self.refreshStok()
|
||||
return True
|
||||
|
||||
def refreshStok(self):
|
||||
url = "https://{host}".format(host=self.host)
|
||||
data = {
|
||||
"method": "login",
|
||||
"params": {
|
||||
"hashed": True,
|
||||
"password": self.hashedPassword,
|
||||
"username": self.user,
|
||||
},
|
||||
}
|
||||
res = requests.post(
|
||||
url, data=json.dumps(data), headers=self.headers, verify=False
|
||||
)
|
||||
if res.status_code == 401:
|
||||
try:
|
||||
data = res.json()
|
||||
if data["result"]["data"]["code"] == -40411:
|
||||
raise Exception("Invalid authentication data")
|
||||
except Exception as e:
|
||||
if str(e) == "Invalid authentication data":
|
||||
raise e
|
||||
else:
|
||||
pass
|
||||
|
||||
if self.responseIsOK(res):
|
||||
self.stok = res.json()["result"]["stok"]
|
||||
return self.stok
|
||||
raise Exception("Invalid authentication data")
|
||||
|
||||
def responseIsOK(self, res):
|
||||
if res.status_code != 200:
|
||||
raise Exception(
|
||||
"Error communicating with Tapo Camera. Status code: "
|
||||
+ str(res.status_code)
|
||||
)
|
||||
try:
|
||||
data = res.json()
|
||||
if "error_code" not in data or data["error_code"] == 0:
|
||||
return True
|
||||
except Exception as e:
|
||||
raise Exception("Unexpected response from Tapo Camera: " + str(e))
|
||||
|
||||
def executeFunction(self, method, params):
|
||||
if method == "multipleRequest":
|
||||
data = self.performRequest({"method": "multipleRequest", "params": params})[
|
||||
"result"
|
||||
]["responses"]
|
||||
else:
|
||||
data = self.performRequest(
|
||||
{
|
||||
"method": "multipleRequest",
|
||||
"params": {"requests": [{"method": method, "params": params}]},
|
||||
}
|
||||
)["result"]["responses"][0]
|
||||
|
||||
if type(data) == list:
|
||||
return data
|
||||
|
||||
if "result" in data:
|
||||
return data["result"]
|
||||
else:
|
||||
raise Exception(
|
||||
"Error: {}, Response: {}".format(
|
||||
data["err_msg"]
|
||||
if "err_msg" in data
|
||||
else self.getErrorMessage(data["error_code"]),
|
||||
json.dumps(data),
|
||||
)
|
||||
)
|
||||
|
||||
def performRequest(self, requestData, loginRetryCount=0):
|
||||
self.ensureAuthenticated()
|
||||
url = self.getHostURL()
|
||||
if self.childID:
|
||||
fullRequest = {
|
||||
"method": "multipleRequest",
|
||||
"params": {
|
||||
"requests": [
|
||||
{
|
||||
"method": "controlChild",
|
||||
"params": {
|
||||
"childControl": {
|
||||
"device_id": self.childID,
|
||||
"request_data": requestData,
|
||||
}
|
||||
},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
else:
|
||||
fullRequest = requestData
|
||||
res = requests.post(
|
||||
url, data=json.dumps(fullRequest), headers=self.headers, verify=False
|
||||
)
|
||||
if not self.responseIsOK(res):
|
||||
data = json.loads(res.text)
|
||||
# -40401: Invalid Stok
|
||||
if (
|
||||
data
|
||||
and "error_code" in data
|
||||
and data["error_code"] == -40401
|
||||
and loginRetryCount < MAX_LOGIN_RETRIES
|
||||
):
|
||||
self.refreshStok()
|
||||
return self.performRequest(requestData, loginRetryCount + 1)
|
||||
else:
|
||||
raise Exception(
|
||||
"Error: {}, Response: {}".format(
|
||||
self.getErrorMessage(data["error_code"]), json.dumps(data)
|
||||
)
|
||||
)
|
||||
|
||||
responseJSON = res.json()
|
||||
# strip away child device stuff to ensure consistent response format for HUB cameras
|
||||
if self.childID:
|
||||
responses = []
|
||||
for response in responseJSON["result"]["responses"]:
|
||||
if "method" in response and response["method"] == "controlChild":
|
||||
if "response_data" in response["result"]:
|
||||
responses.append(response["result"]["response_data"])
|
||||
else:
|
||||
responses.append(response["result"])
|
||||
else:
|
||||
responses.append(response["result"]) # not sure if needed
|
||||
responseJSON["result"]["responses"] = responses
|
||||
return responseJSON["result"]["responses"][0]
|
||||
elif self.responseIsOK(res):
|
||||
return responseJSON
|
||||
|
||||
def getMediaSession(self):
|
||||
return HttpMediaSession(
|
||||
self.host, self.cloudPassword, self.superSecretKey
|
||||
) # pragma: no cover
|
||||
|
||||
def getChildDevices(self):
|
||||
childDevices = self.performRequest(
|
||||
{
|
||||
"method": "getChildDeviceList",
|
||||
"params": {"childControl": {"start_index": 0}},
|
||||
}
|
||||
)
|
||||
return childDevices["result"]["child_device_list"]
|
||||
|
||||
# returns empty response for child devices
|
||||
def getOsd(self):
|
||||
# no, asking for all does not work...
|
||||
if self.childID:
|
||||
return self.executeFunction(
|
||||
"getOsd", {"OSD": {"name": ["logo", "date", "label"]}},
|
||||
)
|
||||
else:
|
||||
return self.executeFunction(
|
||||
"getOsd",
|
||||
{"OSD": {"name": ["date", "week", "font"], "table": ["label_info"]}},
|
||||
)
|
||||
|
||||
def setOsd(
|
||||
self,
|
||||
label,
|
||||
dateEnabled=True,
|
||||
labelEnabled=False,
|
||||
weekEnabled=False,
|
||||
dateX=0,
|
||||
dateY=0,
|
||||
labelX=0,
|
||||
labelY=500,
|
||||
weekX=0,
|
||||
weekY=0,
|
||||
):
|
||||
if self.childID:
|
||||
raise Exception("setOsd not supported for child devices yet")
|
||||
data = {
|
||||
"method": "set",
|
||||
"OSD": {
|
||||
"date": {
|
||||
"enabled": "on" if dateEnabled else "off",
|
||||
"x_coor": dateX,
|
||||
"y_coor": dateY,
|
||||
},
|
||||
"week": {
|
||||
"enabled": "on" if weekEnabled else "off",
|
||||
"x_coor": weekX,
|
||||
"y_coor": weekY,
|
||||
},
|
||||
"font": {
|
||||
"color": "white",
|
||||
"color_type": "auto",
|
||||
"display": "ntnb",
|
||||
"size": "auto",
|
||||
},
|
||||
"label_info_1": {
|
||||
"enabled": "on" if labelEnabled else "off",
|
||||
"x_coor": labelX,
|
||||
"y_coor": labelY,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
if len(label) >= 16:
|
||||
raise Exception("Error: Label cannot be longer than 16 characters")
|
||||
elif len(label) == 0:
|
||||
data["OSD"]["label_info_1"]["enabled"] = "off"
|
||||
else:
|
||||
data["OSD"]["label_info_1"]["text"] = label
|
||||
if (
|
||||
dateX > 10000
|
||||
or dateX < 0
|
||||
or labelX > 10000
|
||||
or labelX < 0
|
||||
or weekX > 10000
|
||||
or weekX < 0
|
||||
or dateY > 10000
|
||||
or dateY < 0
|
||||
or labelY > 10000
|
||||
or labelY < 0
|
||||
or weekY > 10000
|
||||
or weekY < 0
|
||||
):
|
||||
raise Exception("Error: Incorrect corrdinates, must be between 0 and 10000")
|
||||
|
||||
return self.performRequest(data)
|
||||
|
||||
# does not work for child devices, function discovery needed
|
||||
def getModuleSpec(self):
|
||||
return self.performRequest(
|
||||
{"method": "get", "function": {"name": ["module_spec"]}}
|
||||
)
|
||||
|
||||
def getPrivacyMode(self):
|
||||
data = self.executeFunction(
|
||||
"getLensMaskConfig", {"lens_mask": {"name": ["lens_mask_info"]}},
|
||||
)
|
||||
return data["lens_mask"]["lens_mask_info"]
|
||||
|
||||
def getMediaEncrypt(self):
|
||||
data = self.executeFunction(
|
||||
"getMediaEncrypt", {"cet": {"name": ["media_encrypt"]}},
|
||||
)
|
||||
return data["cet"]["media_encrypt"]
|
||||
|
||||
def getMotionDetection(self):
|
||||
return self.executeFunction(
|
||||
"getDetectionConfig", {"motion_detection": {"name": ["motion_det"]}},
|
||||
)["motion_detection"]["motion_det"]
|
||||
|
||||
def getPersonDetection(self):
|
||||
data = {"method": "get", "people_detection": {"name": ["detection"]}}
|
||||
return self.performRequest(data)["people_detection"]["detection"]
|
||||
|
||||
def getAlarm(self):
|
||||
# ensure reverse compatibility, simulate the same response for children devices
|
||||
if self.childID:
|
||||
data = self.getAlarmConfig()
|
||||
|
||||
# replace "siren" with "sound", some cameras call it siren, some sound
|
||||
for i in range(len(data[0]["result"]["alarm_mode"])):
|
||||
if data[0]["result"]["alarm_mode"][i] == "siren":
|
||||
data[0]["result"]["alarm_mode"][i] = "sound"
|
||||
return {
|
||||
"alarm_type": "0",
|
||||
"light_type": "0",
|
||||
"enabled": data[0]["result"]["enabled"],
|
||||
"alarm_mode": data[0]["result"]["alarm_mode"],
|
||||
}
|
||||
else:
|
||||
return self.executeFunction(
|
||||
"getLastAlarmInfo", {"msg_alarm": {"name": ["chn1_msg_alarm_info"]}},
|
||||
)["msg_alarm"]["chn1_msg_alarm_info"]
|
||||
|
||||
def getAlarmConfig(self):
|
||||
return self.executeFunction(
|
||||
"multipleRequest",
|
||||
{
|
||||
"requests": [
|
||||
{"method": "getAlarmConfig", "params": {"msg_alarm": {}}},
|
||||
{"method": "getAlarmPlan", "params": {"msg_alarm_plan": {}}},
|
||||
{"method": "getSirenTypeList", "params": {"msg_alarm": {}}},
|
||||
{"method": "getLightTypeList", "params": {"msg_alarm": {}}},
|
||||
{"method": "getSirenStatus", "params": {"msg_alarm": {}}},
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
def getRotationStatus(self):
|
||||
return self.executeFunction(
|
||||
"getRotationStatus", {"image": {"name": ["switch"]}},
|
||||
)
|
||||
|
||||
def getLED(self):
|
||||
return self.executeFunction("getLedStatus", {"led": {"name": ["config"]}},)[
|
||||
"led"
|
||||
]["config"]
|
||||
|
||||
def getAutoTrackTarget(self):
|
||||
return self.executeFunction(
|
||||
"getTargetTrackConfig", {"target_track": {"name": ["target_track_info"]}}
|
||||
)["target_track"]["target_track_info"]
|
||||
|
||||
# does not work for child devices, function discovery needed
|
||||
def getAudioSpec(self):
|
||||
return self.performRequest(
|
||||
{
|
||||
"method": "get",
|
||||
"audio_capability": {"name": ["device_speaker", "device_microphone"]},
|
||||
}
|
||||
)
|
||||
|
||||
# does not work for child devices, function discovery needed
|
||||
def getVhttpd(self):
|
||||
return self.performRequest({"method": "get", "cet": {"name": ["vhttpd"]}})
|
||||
|
||||
def getBasicInfo(self):
|
||||
return self.executeFunction(
|
||||
"getDeviceInfo", {"device_info": {"name": ["basic_info"]}}
|
||||
)
|
||||
|
||||
def getTime(self):
|
||||
return self.executeFunction(
|
||||
"getClockStatus", {"system": {"name": "clock_status"}}
|
||||
)
|
||||
|
||||
# does not work for child devices, function discovery needed
|
||||
def getMotorCapability(self):
|
||||
return self.performRequest({"method": "get", "motor": {"name": ["capability"]}})
|
||||
|
||||
def setPrivacyMode(self, enabled):
|
||||
return self.executeFunction(
|
||||
"setLensMaskConfig",
|
||||
{"lens_mask": {"lens_mask_info": {"enabled": "on" if enabled else "off"}}},
|
||||
)
|
||||
|
||||
def setMediaEncrypt(self, enabled):
|
||||
return self.executeFunction(
|
||||
"setMediaEncrypt",
|
||||
{"cet": {"media_encrypt": {"enabled": "on" if enabled else "off"}}},
|
||||
)
|
||||
|
||||
# todo child
|
||||
def setAlarm(self, enabled, soundEnabled=True, lightEnabled=True):
|
||||
alarm_mode = []
|
||||
|
||||
if not soundEnabled and not lightEnabled:
|
||||
raise Exception("You need to use at least sound or light for alarm")
|
||||
|
||||
if soundEnabled:
|
||||
if self.childID:
|
||||
alarm_mode.append("siren")
|
||||
else:
|
||||
alarm_mode.append("sound")
|
||||
if lightEnabled:
|
||||
alarm_mode.append("light")
|
||||
|
||||
if self.childID:
|
||||
data = {
|
||||
"msg_alarm": {
|
||||
"enabled": "on" if enabled else "off",
|
||||
"alarm_mode": alarm_mode,
|
||||
}
|
||||
}
|
||||
return self.executeFunction("setAlarmConfig", data)
|
||||
else:
|
||||
data = {
|
||||
"method": "set",
|
||||
"msg_alarm": {
|
||||
"chn1_msg_alarm_info": {
|
||||
"alarm_type": "0",
|
||||
"enabled": "on" if enabled else "off",
|
||||
"light_type": "0",
|
||||
"alarm_mode": alarm_mode,
|
||||
}
|
||||
},
|
||||
}
|
||||
return self.performRequest(data)
|
||||
|
||||
# todo child
|
||||
def moveMotor(self, x, y):
|
||||
return self.performRequest(
|
||||
{"method": "do", "motor": {"move": {"x_coord": str(x), "y_coord": str(y)}}}
|
||||
)
|
||||
|
||||
# todo child
|
||||
def moveMotorStep(self, angle):
|
||||
if not (0 <= angle < 360):
|
||||
raise Exception("Angle must be in a range 0 <= angle < 360")
|
||||
|
||||
return self.performRequest(
|
||||
{"method": "do", "motor": {"movestep": {"direction": str(angle)}}}
|
||||
)
|
||||
|
||||
def moveMotorClockWise(self):
|
||||
return self.moveMotorStep(0)
|
||||
|
||||
def moveMotorCounterClockWise(self):
|
||||
return self.moveMotorStep(180)
|
||||
|
||||
def moveMotorVertical(self):
|
||||
return self.moveMotorStep(90)
|
||||
|
||||
def moveMotorHorizontal(self):
|
||||
return self.moveMotorStep(270)
|
||||
|
||||
# todo child
|
||||
def calibrateMotor(self):
|
||||
return self.performRequest({"method": "do", "motor": {"manual_cali": ""}})
|
||||
|
||||
def format(self):
|
||||
return self.executeFunction(
|
||||
"formatSdCard", {"harddisk_manage": {"format_hd": "1"}}
|
||||
) # pragma: no cover
|
||||
|
||||
def setLEDEnabled(self, enabled):
|
||||
return self.executeFunction(
|
||||
"setLedStatus", {"led": {"config": {"enabled": "on" if enabled else "off"}}}
|
||||
)
|
||||
|
||||
def getUserID(self):
|
||||
if not self.userID:
|
||||
self.userID = self.performRequest(
|
||||
{
|
||||
"method": "multipleRequest",
|
||||
"params": {
|
||||
"requests": [
|
||||
{
|
||||
"method": "getUserID",
|
||||
"params": {"system": {"get_user_id": "null"}},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)["result"]["responses"][0]["result"]["user_id"]
|
||||
return self.userID
|
||||
|
||||
def getRecordings(self, date):
|
||||
result = self.executeFunction(
|
||||
"searchVideoOfDay",
|
||||
{
|
||||
"playback": {
|
||||
"search_video_utility": {
|
||||
"channel": 0,
|
||||
"date": date,
|
||||
"end_index": 99,
|
||||
"id": self.getUserID(),
|
||||
"start_index": 0,
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
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)
|
||||
return self.performRequest({"method": "get", "image": {"name": "common"}})
|
||||
|
||||
def setMotionDetection(self, enabled, sensitivity=False):
|
||||
data = {
|
||||
"motion_detection": {"motion_det": {"enabled": "on" if enabled else "off"}},
|
||||
}
|
||||
if sensitivity:
|
||||
if sensitivity == "high":
|
||||
data["motion_detection"]["motion_det"]["digital_sensitivity"] = "80"
|
||||
elif sensitivity == "normal":
|
||||
data["motion_detection"]["motion_det"]["digital_sensitivity"] = "50"
|
||||
elif sensitivity == "low":
|
||||
data["motion_detection"]["motion_det"]["digital_sensitivity"] = "20"
|
||||
else:
|
||||
raise Exception("Invalid sensitivity, can be low, normal or high")
|
||||
# child devices always need digital_sensitivity setting
|
||||
if (
|
||||
self.childID
|
||||
and "digital_sensitivity" not in data["motion_detection"]["motion_det"]
|
||||
):
|
||||
currentData = self.getMotionDetection()
|
||||
data["motion_detection"]["motion_det"]["digital_sensitivity"] = currentData[
|
||||
"digital_sensitivity"
|
||||
]
|
||||
return self.executeFunction("setDetectionConfig", data)
|
||||
|
||||
def setPersonDetection(self, enabled, sensitivity=False):
|
||||
data = {
|
||||
"method": "set",
|
||||
"people_detection": {"detection": {"enabled": "on" if enabled else "off"}},
|
||||
}
|
||||
if sensitivity:
|
||||
if sensitivity == "high":
|
||||
data["people_detection"]["detection"]["sensitivity"] = "80"
|
||||
elif sensitivity == "normal":
|
||||
data["people_detection"]["detection"]["sensitivity"] = "50"
|
||||
elif sensitivity == "low":
|
||||
data["people_detection"]["detection"]["sensitivity"] = "20"
|
||||
else:
|
||||
raise Exception("Invalid sensitivity, can be low, normal or high")
|
||||
|
||||
return self.performRequest(data)
|
||||
|
||||
def setAutoTrackTarget(self, enabled):
|
||||
return self.executeFunction(
|
||||
"setTargetTrackConfig",
|
||||
{
|
||||
"target_track": {
|
||||
"target_track_info": {"enabled": "on" if enabled else "off"}
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
def reboot(self):
|
||||
return self.executeFunction("rebootDevice", {"system": {"reboot": "null"}})
|
||||
|
||||
def getPresets(self):
|
||||
data = self.executeFunction("getPresetConfig", {"preset": {"name": ["preset"]}})
|
||||
self.presets = {
|
||||
id: data["preset"]["preset"]["name"][key]
|
||||
for key, id in enumerate(data["preset"]["preset"]["id"])
|
||||
}
|
||||
return self.presets
|
||||
|
||||
def savePreset(self, name):
|
||||
self.executeFunction(
|
||||
"addMotorPostion", # yes, there is a typo in function name
|
||||
{"preset": {"set_preset": {"name": str(name), "save_ptz": "1"}}},
|
||||
)
|
||||
self.getPresets()
|
||||
return True
|
||||
|
||||
def deletePreset(self, presetID):
|
||||
if not str(presetID) in self.presets:
|
||||
raise Exception("Preset {} is not set in the app".format(str(presetID)))
|
||||
|
||||
self.executeFunction(
|
||||
"deletePreset", {"preset": {"remove_preset": {"id": [presetID]}}}
|
||||
)
|
||||
self.getPresets()
|
||||
return True
|
||||
|
||||
def setPreset(self, presetID):
|
||||
if not str(presetID) in self.presets:
|
||||
raise Exception("Preset {} is not set in the app".format(str(presetID)))
|
||||
return self.executeFunction(
|
||||
"motorMoveToPreset", {"preset": {"goto_preset": {"id": str(presetID)}}}
|
||||
)
|
||||
|
||||
# Switches
|
||||
|
||||
def __getImageSwitch(self, switch: str) -> str:
|
||||
data = self.executeFunction("getLdc", {"image": {"name": ["switch"]}})
|
||||
switches = data["image"]["switch"]
|
||||
if switch not in switches:
|
||||
raise Exception("Switch {} is not supported by this camera".format(switch))
|
||||
return switches[switch]
|
||||
|
||||
def __setImageSwitch(self, switch: str, value: str):
|
||||
return self.executeFunction("setLdc", {"image": {"switch": {switch: value}}})
|
||||
|
||||
def getLensDistortionCorrection(self):
|
||||
return self.__getImageSwitch("ldc") == "on"
|
||||
|
||||
def setLensDistortionCorrection(self, enable):
|
||||
return self.__setImageSwitch("ldc", "on" if enable else "off")
|
||||
|
||||
def getDayNightMode(self) -> str:
|
||||
if self.childID:
|
||||
rawValue = self.getNightVisionModeConfig()["image"]["switch"][
|
||||
"night_vision_mode"
|
||||
]
|
||||
if rawValue == "inf_night_vision":
|
||||
return "on"
|
||||
elif rawValue == "wtl_night_vision":
|
||||
return "off"
|
||||
elif rawValue == "md_night_vision":
|
||||
return "auto"
|
||||
else:
|
||||
return self.__getImageCommon("inf_type")
|
||||
|
||||
def setDayNightMode(self, mode):
|
||||
allowed_modes = ["off", "on", "auto"]
|
||||
if mode not in allowed_modes:
|
||||
raise Exception("Day night mode must be one of {}".format(allowed_modes))
|
||||
if self.childID:
|
||||
if mode == "on":
|
||||
return self.setNightVisionModeConfig("inf_night_vision")
|
||||
elif mode == "off":
|
||||
return self.setNightVisionModeConfig("wtl_night_vision")
|
||||
elif mode == "auto":
|
||||
return self.setNightVisionModeConfig("md_night_vision")
|
||||
else:
|
||||
return self.__setImageCommon("inf_type", mode)
|
||||
|
||||
def getNightVisionModeConfig(self):
|
||||
return self.executeFunction(
|
||||
"getNightVisionModeConfig", {"image": {"name": "switch"}}
|
||||
)
|
||||
|
||||
def setNightVisionModeConfig(self, mode):
|
||||
return self.executeFunction(
|
||||
"setNightVisionModeConfig",
|
||||
{"image": {"switch": {"night_vision_mode": mode}}},
|
||||
)
|
||||
|
||||
def getImageFlipVertical(self):
|
||||
if self.childID:
|
||||
return self.getRotationStatus()["image"]["switch"]["flip_type"] == "center"
|
||||
else:
|
||||
return self.__getImageSwitch("flip_type") == "center"
|
||||
|
||||
def setImageFlipVertical(self, enable):
|
||||
if self.childID:
|
||||
return self.setRotationStatus("center" if enable else "off")
|
||||
else:
|
||||
return self.__setImageSwitch("flip_type", "center" if enable else "off")
|
||||
|
||||
def setRotationStatus(self, flip_type):
|
||||
return self.executeFunction(
|
||||
"setRotationStatus", {"image": {"switch": {"flip_type": flip_type}}},
|
||||
)
|
||||
|
||||
def getForceWhitelampState(self) -> bool:
|
||||
return self.__getImageSwitch("force_wtl_state") == "on"
|
||||
|
||||
def setForceWhitelampState(self, enable: bool):
|
||||
return self.__setImageSwitch("force_wtl_state", "on" if enable else "off")
|
||||
|
||||
# Common
|
||||
|
||||
def __getImageCommon(self, field: str) -> str:
|
||||
data = self.executeFunction(
|
||||
"getLightFrequencyInfo", {"image": {"name": "common"}}
|
||||
)
|
||||
if "common" not in data["image"]:
|
||||
raise Exception("__getImageCommon is not supported by this camera")
|
||||
fields = data["image"]["common"]
|
||||
if field not in fields:
|
||||
raise Exception("Field {} is not supported by this camera".format(field))
|
||||
return fields[field]
|
||||
|
||||
def __setImageCommon(self, field: str, value: str):
|
||||
return self.executeFunction(
|
||||
"setLightFrequencyInfo", {"image": {"common": {field: value}}}
|
||||
)
|
||||
|
||||
def getLightFrequencyMode(self) -> str:
|
||||
return self.__getImageCommon("light_freq_mode")
|
||||
|
||||
def setLightFrequencyMode(self, mode):
|
||||
# todo: auto does not work on some child cameras?
|
||||
allowed_modes = ["auto", "50", "60"]
|
||||
if mode not in allowed_modes:
|
||||
raise Exception(
|
||||
"Light frequency mode must be one of {}".format(allowed_modes)
|
||||
)
|
||||
return self.__setImageCommon("light_freq_mode", mode)
|
||||
|
||||
# does not work for child devices, function discovery needed
|
||||
def startManualAlarm(self):
|
||||
return self.performRequest(
|
||||
{"method": "do", "msg_alarm": {"manual_msg_alarm": {"action": "start"}},}
|
||||
)
|
||||
|
||||
# does not work for child devices, function discovery needed
|
||||
def stopManualAlarm(self):
|
||||
return self.performRequest(
|
||||
{"method": "do", "msg_alarm": {"manual_msg_alarm": {"action": "stop"}},}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def getErrorMessage(errorCode):
|
||||
if str(errorCode) in ERROR_CODES:
|
||||
return str(ERROR_CODES[str(errorCode)])
|
||||
else:
|
||||
return str(errorCode)
|
||||
|
||||
def getFirmwareUpdateStatus(self):
|
||||
return self.executeFunction(
|
||||
"getFirmwareUpdateStatus", {"cloud_config": {"name": "upgrade_status"}}
|
||||
)
|
||||
|
||||
def isUpdateAvailable(self):
|
||||
return self.performRequest(
|
||||
{
|
||||
"method": "multipleRequest",
|
||||
"params": {
|
||||
"requests": [
|
||||
{
|
||||
"method": "checkFirmwareVersionByCloud",
|
||||
"params": {"cloud_config": {"check_fw_version": "null"}},
|
||||
},
|
||||
{
|
||||
"method": "getCloudConfig",
|
||||
"params": {"cloud_config": {"name": ["upgrade_info"]}},
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def startFirmwareUpgrade(self):
|
||||
try:
|
||||
self.performRequest(
|
||||
{"method": "do", "cloud_config": {"fw_download": "null"}}
|
||||
)
|
||||
except Exception:
|
||||
raise Exception("No new firmware available.")
|
||||
|
||||
# Used for purposes of HomeAssistant-Tapo-Control
|
||||
# Uses method names from https://md.depau.eu/s/r1Ys_oWoP
|
||||
def getMost(self):
|
||||
requestData = {
|
||||
"method": "multipleRequest",
|
||||
"params": {
|
||||
"requests": [
|
||||
{
|
||||
"method": "getDeviceInfo",
|
||||
"params": {"device_info": {"name": ["basic_info"]}},
|
||||
},
|
||||
{
|
||||
"method": "getDetectionConfig",
|
||||
"params": {"motion_detection": {"name": ["motion_det"]}},
|
||||
},
|
||||
{
|
||||
"method": "getPersonDetectionConfig",
|
||||
"params": {"people_detection": {"name": ["detection"]}},
|
||||
},
|
||||
{
|
||||
"method": "getLensMaskConfig",
|
||||
"params": {"lens_mask": {"name": ["lens_mask_info"]}},
|
||||
},
|
||||
{
|
||||
"method": "getLdc",
|
||||
"params": {"image": {"name": ["switch", "common"]}},
|
||||
},
|
||||
{
|
||||
"method": "getLastAlarmInfo",
|
||||
"params": {"msg_alarm": {"name": ["chn1_msg_alarm_info"]}},
|
||||
},
|
||||
{
|
||||
"method": "getLedStatus",
|
||||
"params": {"led": {"name": ["config"]}},
|
||||
},
|
||||
{
|
||||
"method": "getTargetTrackConfig",
|
||||
"params": {"target_track": {"name": ["target_track_info"]}},
|
||||
},
|
||||
{
|
||||
"method": "getPresetConfig",
|
||||
"params": {"preset": {"name": ["preset"]}},
|
||||
},
|
||||
{
|
||||
"method": "getFirmwareUpdateStatus",
|
||||
"params": {"cloud_config": {"name": "upgrade_status"}},
|
||||
},
|
||||
{
|
||||
"method": "getMediaEncrypt",
|
||||
"params": {"cet": {"name": ["media_encrypt"]}},
|
||||
},
|
||||
{
|
||||
"method": "getConnectionType",
|
||||
"params": {"network": {"get_connection_type": []}},
|
||||
},
|
||||
{"method": "getAlarmConfig", "params": {"msg_alarm": {}}},
|
||||
{"method": "getAlarmPlan", "params": {"msg_alarm_plan": {}}},
|
||||
{"method": "getSirenTypeList", "params": {"msg_alarm": {}}},
|
||||
{"method": "getLightTypeList", "params": {"msg_alarm": {}}},
|
||||
{"method": "getSirenStatus", "params": {"msg_alarm": {}}},
|
||||
{
|
||||
"method": "getLightFrequencyInfo",
|
||||
"params": {"image": {"name": "common"}},
|
||||
},
|
||||
{
|
||||
"method": "getLightFrequencyCapability",
|
||||
"params": {"image": {"name": "common"}},
|
||||
},
|
||||
{
|
||||
"method": "getChildDeviceList",
|
||||
"params": {"childControl": {"start_index": 0}},
|
||||
},
|
||||
{
|
||||
"method": "getRotationStatus",
|
||||
"params": {"image": {"name": ["switch"]}},
|
||||
},
|
||||
{
|
||||
"method": "getNightVisionModeConfig",
|
||||
"params": {"image": {"name": "switch"}},
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
results = self.performRequest(requestData)
|
||||
|
||||
returnData = {}
|
||||
# todo finish on child
|
||||
i = 0
|
||||
for result in results["result"]["responses"]:
|
||||
if (
|
||||
"error_code" in result and result["error_code"] == 0
|
||||
) and "result" in result:
|
||||
returnData[result["method"]] = result["result"]
|
||||
else:
|
||||
if "method" in result:
|
||||
returnData[result["method"]] = False
|
||||
else: # some cameras are not returning method for error messages
|
||||
returnData[requestData["params"]["requests"][i]["method"]] = False
|
||||
i += 1
|
||||
return returnData
|
BIN
deps/lib/python3.10/site-packages/pytapo/__pycache__/ERROR_CODES.cpython-310.pyc
vendored
Normal file
BIN
deps/lib/python3.10/site-packages/pytapo/__pycache__/ERROR_CODES.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/lib/python3.10/site-packages/pytapo/__pycache__/__init__.cpython-310.pyc
vendored
Normal file
BIN
deps/lib/python3.10/site-packages/pytapo/__pycache__/__init__.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/lib/python3.10/site-packages/pytapo/__pycache__/const.cpython-310.pyc
vendored
Normal file
BIN
deps/lib/python3.10/site-packages/pytapo/__pycache__/const.cpython-310.pyc
vendored
Normal file
Binary file not shown.
11
deps/lib/python3.10/site-packages/pytapo/const.py
vendored
Normal file
11
deps/lib/python3.10/site-packages/pytapo/const.py
vendored
Normal file
@ -0,0 +1,11 @@
|
||||
ERROR_CODES = {
|
||||
"-40401": "Invalid stok value",
|
||||
"-64324": "Privacy mode is ON, not able to execute",
|
||||
"-64302": "Preset ID not found",
|
||||
"-64321": "Preset ID was deleted so no longer exists",
|
||||
"-40106": "Parameter to get/do does not exist",
|
||||
"-40105": "Method does not exist",
|
||||
"-40101": "Parameter to set does not exist",
|
||||
"-40209": "Invalid login credentials",
|
||||
}
|
||||
MAX_LOGIN_RETRIES = 2
|
0
deps/lib/python3.10/site-packages/pytapo/media_stream/__init__.py
vendored
Normal file
0
deps/lib/python3.10/site-packages/pytapo/media_stream/__init__.py
vendored
Normal file
BIN
deps/lib/python3.10/site-packages/pytapo/media_stream/__pycache__/__init__.cpython-310.pyc
vendored
Normal file
BIN
deps/lib/python3.10/site-packages/pytapo/media_stream/__pycache__/__init__.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/lib/python3.10/site-packages/pytapo/media_stream/__pycache__/_utils.cpython-310.pyc
vendored
Normal file
BIN
deps/lib/python3.10/site-packages/pytapo/media_stream/__pycache__/_utils.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/lib/python3.10/site-packages/pytapo/media_stream/__pycache__/crypto.cpython-310.pyc
vendored
Normal file
BIN
deps/lib/python3.10/site-packages/pytapo/media_stream/__pycache__/crypto.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/lib/python3.10/site-packages/pytapo/media_stream/__pycache__/error.cpython-310.pyc
vendored
Normal file
BIN
deps/lib/python3.10/site-packages/pytapo/media_stream/__pycache__/error.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/lib/python3.10/site-packages/pytapo/media_stream/__pycache__/response.cpython-310.pyc
vendored
Normal file
BIN
deps/lib/python3.10/site-packages/pytapo/media_stream/__pycache__/response.cpython-310.pyc
vendored
Normal file
Binary file not shown.
BIN
deps/lib/python3.10/site-packages/pytapo/media_stream/__pycache__/session.cpython-310.pyc
vendored
Normal file
BIN
deps/lib/python3.10/site-packages/pytapo/media_stream/__pycache__/session.cpython-310.pyc
vendored
Normal file
Binary file not shown.
29
deps/lib/python3.10/site-packages/pytapo/media_stream/_utils.py
vendored
Normal file
29
deps/lib/python3.10/site-packages/pytapo/media_stream/_utils.py
vendored
Normal file
@ -0,0 +1,29 @@
|
||||
import hashlib
|
||||
import os
|
||||
|
||||
from typing import Mapping, Tuple, Optional
|
||||
|
||||
|
||||
def md5digest(to_hash: bytes) -> bytes:
|
||||
return hashlib.md5(to_hash).digest().hex().upper().encode()
|
||||
|
||||
|
||||
def generate_nonce(length: int) -> bytes:
|
||||
return os.urandom(length).hex().encode()
|
||||
|
||||
|
||||
def parse_http_headers(data: bytes) -> Mapping[str, str]:
|
||||
return {
|
||||
i[0].strip(): i[1].strip()
|
||||
for i in (j.split(":", 1) for j in data.decode().strip().split("\r\n"))
|
||||
}
|
||||
|
||||
|
||||
def parse_http_response(res_line: bytes) -> Tuple[bytes, int, Optional[bytes]]:
|
||||
http_ver, status_code_and_status = res_line.split(b" ", 1)
|
||||
if b" " in status_code_and_status:
|
||||
status_code, status = status_code_and_status.split(b" ", 1)
|
||||
else:
|
||||
status_code = status_code_and_status
|
||||
status = None
|
||||
return http_ver, int(status_code.decode()), status
|
73
deps/lib/python3.10/site-packages/pytapo/media_stream/crypto.py
vendored
Normal file
73
deps/lib/python3.10/site-packages/pytapo/media_stream/crypto.py
vendored
Normal file
@ -0,0 +1,73 @@
|
||||
import hashlib
|
||||
import logging
|
||||
from typing import AnyStr
|
||||
|
||||
from Crypto.Cipher import AES
|
||||
from Crypto.Util.Padding import pad, unpad
|
||||
|
||||
from pytapo.media_stream.error import NonceMissingException
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AESHelper:
|
||||
def __init__(
|
||||
self,
|
||||
username: bytes,
|
||||
nonce: bytes,
|
||||
cloud_password: bytes,
|
||||
super_secret_key: bytes,
|
||||
):
|
||||
if not nonce:
|
||||
raise NonceMissingException()
|
||||
self.nonce = nonce
|
||||
|
||||
hashed_pwd = hashlib.md5(cloud_password).hexdigest().upper().encode()
|
||||
if username == b"none":
|
||||
logger.debug(
|
||||
"Detected turned off media encryption, using super secret key."
|
||||
)
|
||||
if super_secret_key == b"":
|
||||
raise Exception(
|
||||
"Media encryption is off and super secret key is not set."
|
||||
)
|
||||
key = hashlib.md5(nonce + b":" + super_secret_key).digest()
|
||||
else:
|
||||
logger.debug("Detected turned on media encryption, using cloud password.")
|
||||
key = hashlib.md5(nonce + b":" + hashed_pwd).digest()
|
||||
|
||||
iv = hashlib.md5(username + b":" + nonce).digest()
|
||||
|
||||
self._cipher = AES.new(key, AES.MODE_CBC, iv)
|
||||
|
||||
logger.debug("AES cipher set up correctly")
|
||||
|
||||
@classmethod
|
||||
def from_keyexchange_and_password(
|
||||
cls, key_exchange: AnyStr, cloud_password: AnyStr, super_secret_key: AnyStr
|
||||
):
|
||||
if type(cloud_password) == str:
|
||||
cloud_password = cloud_password.encode()
|
||||
if type(key_exchange) == str:
|
||||
key_exchange = key_exchange.encode()
|
||||
|
||||
key_exchange = {
|
||||
i[0].strip().replace(b'"', b""): i[1].strip().replace(b'"', b"")
|
||||
for i in (j.split(b"=", 1) for j in key_exchange.split(b" "))
|
||||
}
|
||||
|
||||
if b"nonce" not in key_exchange:
|
||||
raise NonceMissingException()
|
||||
|
||||
return cls(
|
||||
key_exchange[b"username"],
|
||||
key_exchange[b"nonce"],
|
||||
cloud_password,
|
||||
super_secret_key,
|
||||
)
|
||||
|
||||
def decrypt(self, data: bytes) -> bytes:
|
||||
return unpad(self._cipher.decrypt(data), 16, style="pkcs7")
|
||||
|
||||
def encrypt(self, data: bytes) -> bytes:
|
||||
return self._cipher.encrypt(pad(data, 16, style="pkcs7"))
|
17
deps/lib/python3.10/site-packages/pytapo/media_stream/error.py
vendored
Normal file
17
deps/lib/python3.10/site-packages/pytapo/media_stream/error.py
vendored
Normal file
@ -0,0 +1,17 @@
|
||||
class HttpMediaSessionException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NonceMissingException(HttpMediaSessionException, ValueError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Nonce is missing from key exchange")
|
||||
|
||||
|
||||
class HttpStatusCodeException(HttpMediaSessionException):
|
||||
def __init__(self, status_code: int) -> None:
|
||||
super().__init__("HTTP request returned {} status code".format(status_code))
|
||||
|
||||
|
||||
class KeyExchangeMissingException(HttpMediaSessionException, RuntimeError):
|
||||
def __init__(self) -> None:
|
||||
super().__init__("Server reply does not contain the required Key-Exchange")
|
23
deps/lib/python3.10/site-packages/pytapo/media_stream/response.py
vendored
Normal file
23
deps/lib/python3.10/site-packages/pytapo/media_stream/response.py
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
from typing import Mapping, Optional
|
||||
|
||||
|
||||
class HttpMediaResponse:
|
||||
def __init__(
|
||||
self,
|
||||
seq: Optional[int],
|
||||
session: Optional[int],
|
||||
headers: Mapping[str, str],
|
||||
encrypted: bool,
|
||||
mimetype: str,
|
||||
ciphertext: Optional[bytes],
|
||||
plaintext: bytes,
|
||||
json_data,
|
||||
):
|
||||
self.seq = seq
|
||||
self.session = session
|
||||
self.headers = headers
|
||||
self.encrypted = encrypted
|
||||
self.mimetype = mimetype
|
||||
self.ciphertext = ciphertext
|
||||
self.plaintext = plaintext
|
||||
self.json_data = json_data
|
509
deps/lib/python3.10/site-packages/pytapo/media_stream/session.py
vendored
Normal file
509
deps/lib/python3.10/site-packages/pytapo/media_stream/session.py
vendored
Normal file
@ -0,0 +1,509 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import warnings
|
||||
from asyncio import StreamReader, StreamWriter, Task, Queue
|
||||
from json import JSONDecodeError
|
||||
from typing import Optional, Mapping, Generator, MutableMapping
|
||||
|
||||
from pytapo.media_stream._utils import (
|
||||
generate_nonce,
|
||||
md5digest,
|
||||
parse_http_response,
|
||||
parse_http_headers,
|
||||
)
|
||||
from pytapo.media_stream.crypto import AESHelper
|
||||
from pytapo.media_stream.error import (
|
||||
HttpStatusCodeException,
|
||||
KeyExchangeMissingException,
|
||||
)
|
||||
from pytapo.media_stream.response import HttpMediaResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HttpMediaSession:
|
||||
def __init__(
|
||||
self,
|
||||
ip: str,
|
||||
cloud_password: str,
|
||||
super_secret_key: str,
|
||||
window_size=50,
|
||||
port: int = 8800,
|
||||
username: str = "admin",
|
||||
multipart_boundary: bytes = b"--client-stream-boundary--",
|
||||
):
|
||||
self.ip = ip
|
||||
self.window_size = window_size
|
||||
self.cloud_password = cloud_password
|
||||
self.super_secret_key = super_secret_key
|
||||
self.hashed_password = md5digest(cloud_password.encode()).decode()
|
||||
self.port = port
|
||||
self.username = username
|
||||
self.client_boundary = multipart_boundary
|
||||
|
||||
self._started: bool = False
|
||||
self._response_handler_task: Optional[Task] = None
|
||||
|
||||
self._auth_data: Mapping[str, str] = {}
|
||||
self._authorization: Optional[str] = None
|
||||
self._device_boundary = b"--device-stream-boundary--"
|
||||
self._key_exchange: Optional[str] = None
|
||||
self._aes: Optional[AESHelper] = None
|
||||
|
||||
# Socket stream pair
|
||||
self._reader: Optional[StreamReader] = None
|
||||
self._writer: Optional[StreamWriter] = None
|
||||
|
||||
self._sequence_numbers: MutableMapping[int, Queue] = {}
|
||||
self._sessions: MutableMapping[int, Queue] = {}
|
||||
|
||||
@property
|
||||
def started(self) -> bool:
|
||||
return self._started
|
||||
|
||||
async def __aenter__(self):
|
||||
await self.start()
|
||||
return self
|
||||
|
||||
async def start(self):
|
||||
req_line = b"POST /stream HTTP/1.1"
|
||||
headers = {
|
||||
b"Content-Type": "multipart/mixed;boundary={}".format(
|
||||
self.client_boundary.decode()
|
||||
).encode(),
|
||||
b"Connection": b"keep-alive",
|
||||
b"Content-Length": b"-1",
|
||||
}
|
||||
try:
|
||||
self._reader, self._writer = await asyncio.open_connection(
|
||||
self.ip, self.port
|
||||
)
|
||||
logger.info("Connected to the media streaming server")
|
||||
|
||||
# Step one: perform unauthenticated request
|
||||
await self._send_http_request(req_line, headers)
|
||||
|
||||
data = await self._reader.readuntil(b"\r\n\r\n")
|
||||
res_line, headers_block = data.split(b"\r\n", 1)
|
||||
_, status_code, _ = parse_http_response(res_line)
|
||||
res_headers = parse_http_headers(headers_block)
|
||||
|
||||
self._auth_data = {
|
||||
i[0].strip().replace('"', ""): i[1].strip().replace('"', "")
|
||||
for i in (
|
||||
j.split("=")
|
||||
for j in res_headers["WWW-Authenticate"].split(" ", 1)[1].split(",")
|
||||
)
|
||||
}
|
||||
self._auth_data.update(
|
||||
{
|
||||
"username": self.username,
|
||||
"cnonce": generate_nonce(24).decode(),
|
||||
"nc": "00000001",
|
||||
"qop": "auth",
|
||||
}
|
||||
)
|
||||
|
||||
challenge1 = hashlib.md5(
|
||||
":".join(
|
||||
(self.username, self._auth_data["realm"], self.hashed_password)
|
||||
).encode()
|
||||
).hexdigest()
|
||||
challenge2 = hashlib.md5(b"POST:/stream").hexdigest()
|
||||
|
||||
self._auth_data["response"] = hashlib.md5(
|
||||
b":".join(
|
||||
(
|
||||
challenge1.encode(),
|
||||
self._auth_data["nonce"].encode(),
|
||||
self._auth_data["nc"].encode(),
|
||||
self._auth_data["cnonce"].encode(),
|
||||
self._auth_data["qop"].encode(),
|
||||
challenge2.encode(),
|
||||
)
|
||||
)
|
||||
).hexdigest()
|
||||
|
||||
self._authorization = (
|
||||
'Digest username="{username}",realm="{realm}"'
|
||||
',uri="/stream",algorithm=MD5,'
|
||||
'nonce="{nonce}",nc={nc},cnonce="{cnonce}",qop={qop},'
|
||||
'response="{response}",opaque="{opaque}"'.format(
|
||||
**self._auth_data
|
||||
).encode()
|
||||
)
|
||||
headers[b"Authorization"] = self._authorization
|
||||
|
||||
logger.debug("Authentication data retrieved")
|
||||
|
||||
# Step two: start actual communication
|
||||
await self._send_http_request(req_line, headers)
|
||||
|
||||
# Ensure the request was successful
|
||||
data = await self._reader.readuntil(b"\r\n\r\n")
|
||||
res_line, headers_block = data.split(b"\r\n", 1)
|
||||
_, status_code, _ = parse_http_response(res_line)
|
||||
if status_code != 200:
|
||||
raise HttpStatusCodeException(status_code)
|
||||
|
||||
# Parse important HTTP headers
|
||||
res_headers = parse_http_headers(headers_block)
|
||||
if "Key-Exchange" not in res_headers:
|
||||
raise KeyExchangeMissingException
|
||||
|
||||
boundary = None
|
||||
if "Content-Type" in res_headers:
|
||||
# noinspection PyBroadException
|
||||
try:
|
||||
boundary = filter(
|
||||
lambda chunk: chunk.startswith("boundary="),
|
||||
res_headers["Content-Type"].split(";"),
|
||||
).__next__()
|
||||
boundary = boundary.split("=")[1].encode()
|
||||
except Exception:
|
||||
boundary = None
|
||||
if not boundary:
|
||||
warnings.warn(
|
||||
"Server did not provide a multipart/mixed boundary."
|
||||
+ " Assuming default."
|
||||
)
|
||||
else:
|
||||
self._device_boundary = boundary
|
||||
|
||||
# Prepare for AES decryption of content
|
||||
self._key_exchange = res_headers["Key-Exchange"]
|
||||
self._aes = AESHelper.from_keyexchange_and_password(
|
||||
self._key_exchange.encode(),
|
||||
self.cloud_password.encode(),
|
||||
self.super_secret_key.encode(),
|
||||
)
|
||||
|
||||
logger.debug("AES key exchange performed")
|
||||
|
||||
# Start the response handler in the background to shuffle
|
||||
# responses to the correct callers
|
||||
self._started = True
|
||||
self._response_handler_task = asyncio.create_task(
|
||||
self._device_response_handler_loop()
|
||||
)
|
||||
|
||||
except Exception:
|
||||
# Close socket in case of issues during setup
|
||||
# noinspection PyBroadException
|
||||
try:
|
||||
self._writer.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._started = False
|
||||
raise
|
||||
|
||||
async def _send_http_request(
|
||||
self, delimiter: bytes, headers: Mapping[bytes, bytes]
|
||||
):
|
||||
self._writer.write(delimiter + b"\r\n")
|
||||
for header, value in headers.items():
|
||||
self._writer.write(b": ".join((header, value)) + b"\r\n")
|
||||
await self._writer.drain()
|
||||
|
||||
self._writer.write(b"\r\n")
|
||||
await self._writer.drain()
|
||||
|
||||
async def _device_response_handler_loop(self):
|
||||
logger.debug("Response handler is running")
|
||||
|
||||
while self._started:
|
||||
session = None
|
||||
seq = None
|
||||
|
||||
# We're only interested in what comes after it,
|
||||
# what's before and the boundary goes to the trash
|
||||
await self._reader.readuntil(self._device_boundary)
|
||||
|
||||
logger.debug("Handling new server response")
|
||||
|
||||
# print("got response")
|
||||
|
||||
# Read and parse headers
|
||||
headers_block = await self._reader.readuntil(b"\r\n\r\n")
|
||||
headers = parse_http_headers(headers_block)
|
||||
# print(headers)
|
||||
|
||||
mimetype = headers["Content-Type"]
|
||||
length = int(headers["Content-Length"])
|
||||
encrypted = bool(int(headers["X-If-Encrypt"]))
|
||||
|
||||
if "X-Session-Id" in headers:
|
||||
session = int(headers["X-Session-Id"])
|
||||
if "X-Data-Sequence" in headers:
|
||||
seq = int(headers["X-Data-Sequence"])
|
||||
|
||||
# Now we know the content length, let's read it and decrypt it
|
||||
json_data = None
|
||||
# print("TEST0")
|
||||
data = await self._reader.readexactly(length)
|
||||
if encrypted:
|
||||
# print("encrypted")
|
||||
ciphertext = data
|
||||
# print("TEST1")
|
||||
try:
|
||||
# print("lolo")
|
||||
# print(ciphertext)
|
||||
plaintext = self._aes.decrypt(ciphertext)
|
||||
# if length == 384:
|
||||
# print(plaintext)
|
||||
# print("lala")
|
||||
# print(plaintext)
|
||||
except ValueError as e:
|
||||
# print(e)
|
||||
if "padding is incorrect" in e.args[0].lower():
|
||||
e = ValueError(
|
||||
e.args[0]
|
||||
+ " - This usually means that"
|
||||
+ " the cloud password is incorrect."
|
||||
)
|
||||
plaintext = e
|
||||
except Exception as e:
|
||||
plaintext = e
|
||||
else:
|
||||
# print("plaintext")
|
||||
ciphertext = None
|
||||
plaintext = data
|
||||
# print(plaintext)
|
||||
# JSON responses sometimes have the above info in the payload,
|
||||
# not the headers. Let's parse it.
|
||||
if mimetype == "application/json":
|
||||
try:
|
||||
json_data = json.loads(plaintext.decode())
|
||||
if "seq" in json_data:
|
||||
# print("Setting seq")
|
||||
seq = json_data["seq"]
|
||||
if "params" in json_data and "session_id" in json_data["params"]:
|
||||
session = int(json_data["params"]["session_id"])
|
||||
# print("Setting session")
|
||||
except JSONDecodeError:
|
||||
logger.warning("Unable to parse JSON sent from device")
|
||||
|
||||
if (
|
||||
(session is None)
|
||||
and (seq is None)
|
||||
or (
|
||||
(session is not None)
|
||||
and (session not in self._sessions)
|
||||
and (seq is not None)
|
||||
and (seq not in self._sequence_numbers)
|
||||
)
|
||||
):
|
||||
logger.warning(
|
||||
"Received response with no or invalid session information "
|
||||
"(sequence {}, session {}), can't be delivered".format(seq, session)
|
||||
)
|
||||
continue
|
||||
|
||||
# # Update our own sequence numbers to avoid collisions
|
||||
# if (seq is not None) and (seq > self._seq_counter):
|
||||
# self._seq_counter = seq + 1
|
||||
|
||||
queue: Optional[Queue] = None
|
||||
|
||||
# Move queue to use sessions from now on
|
||||
if (
|
||||
(session is not None)
|
||||
and (seq is not None)
|
||||
and (session not in self._sessions)
|
||||
and (seq in self._sequence_numbers)
|
||||
):
|
||||
queue = self._sequence_numbers.pop(seq)
|
||||
self._sessions[session] = queue
|
||||
elif (session is not None) and (session in self._sessions):
|
||||
queue = self._sessions[session]
|
||||
|
||||
if queue is None:
|
||||
raise AssertionError("BUG! Queue not retrieved and not caught earlier")
|
||||
|
||||
response_obj = HttpMediaResponse(
|
||||
seq=seq,
|
||||
session=session,
|
||||
headers=headers,
|
||||
encrypted=encrypted,
|
||||
mimetype=mimetype,
|
||||
ciphertext=ciphertext,
|
||||
plaintext=plaintext,
|
||||
json_data=json_data,
|
||||
)
|
||||
|
||||
if (
|
||||
seq is not None # never ack live stream
|
||||
and seq % self.window_size == 0
|
||||
and (seq < 2000)
|
||||
): # seq < 2000 is temp
|
||||
# print("sending ack")
|
||||
data = {
|
||||
"type": "notification",
|
||||
"params": {"event_type": "stream_sequence"},
|
||||
}
|
||||
data = json.dumps(data, separators=(",", ":")).encode()
|
||||
headers = {}
|
||||
headers[b"X-Session-Id"] = str(session).encode()
|
||||
headers[b"X-Data-Received"] = str(
|
||||
self.window_size * (seq // self.window_size)
|
||||
).encode()
|
||||
headers[b"Content-Length"] = str(len(data)).encode()
|
||||
logger.debug("Sending acknowledgement...")
|
||||
|
||||
await self._send_http_request(b"--" + self.client_boundary, headers)
|
||||
chunk_size = 4096
|
||||
for i in range(0, len(data), chunk_size):
|
||||
# print(data[i : i + chunk_size])
|
||||
self._writer.write(data[i : i + chunk_size])
|
||||
await self._writer.drain()
|
||||
|
||||
logger.debug(
|
||||
(
|
||||
"{} response of type {} processed (sequence {}, session {})"
|
||||
", dispatching to queue {}"
|
||||
).format(
|
||||
"Encrypted" if encrypted else "Plaintext",
|
||||
mimetype,
|
||||
seq,
|
||||
session,
|
||||
id(queue),
|
||||
)
|
||||
)
|
||||
|
||||
await queue.put(response_obj)
|
||||
|
||||
async def transceive(
|
||||
self,
|
||||
data: str,
|
||||
mimetype: str = "application/json",
|
||||
session: int = None,
|
||||
encrypt: bool = False,
|
||||
no_data_timeout=1.0,
|
||||
) -> Generator[HttpMediaResponse, None, None]:
|
||||
sequence = None
|
||||
queue = None
|
||||
|
||||
if mimetype != "application/json" and session is None:
|
||||
raise ValueError("Non-JSON streams must always be bound to a session")
|
||||
|
||||
if mimetype == "application/json":
|
||||
j = json.loads(data)
|
||||
if "type" in j and j["type"] == "request":
|
||||
# Use random high sequence number to avoid collisions
|
||||
# with sequence numbers from server in queue
|
||||
|
||||
# dispatching
|
||||
sequence = random.randint(1000, 0x7FFF)
|
||||
j["seq"] = sequence
|
||||
data = json.dumps(j, separators=(",", ":"))
|
||||
|
||||
if (
|
||||
(sequence is None)
|
||||
and (session is None)
|
||||
or (session is not None and session not in self._sessions)
|
||||
):
|
||||
raise ValueError(
|
||||
"Data is not a request and no existing session has been found"
|
||||
)
|
||||
|
||||
if session is not None:
|
||||
queue = self._sessions[session]
|
||||
if sequence is not None:
|
||||
queue = asyncio.Queue(128)
|
||||
self._sequence_numbers[sequence] = queue
|
||||
|
||||
if type(data) == str:
|
||||
data = data.encode()
|
||||
|
||||
headers = {
|
||||
b"Content-Type": mimetype.encode(),
|
||||
}
|
||||
|
||||
if encrypt:
|
||||
data = self._aes.encrypt(data)
|
||||
headers[b"X-If-Encrypt"] = b"1"
|
||||
|
||||
headers[b"Content-Length"] = str(len(data)).encode()
|
||||
|
||||
if mimetype != "application/json":
|
||||
headers[b"X-If-Encrypt"] = str(
|
||||
int(encrypt)
|
||||
).encode() # Always sent if data is not JSON
|
||||
if session is not None:
|
||||
headers[b"X-Session-Id"] = str(
|
||||
session
|
||||
).encode() # If JSON, session is included in the payload
|
||||
|
||||
if self.window_size is not None:
|
||||
headers[b"X-Data-Window-Size"] = str(self.window_size).encode()
|
||||
|
||||
await self._send_http_request(b"--" + self.client_boundary, headers)
|
||||
|
||||
chunk_size = 4096
|
||||
# print("Sending:")
|
||||
for i in range(0, len(data), chunk_size):
|
||||
# print(data[i : i + chunk_size])
|
||||
self._writer.write(data[i : i + chunk_size])
|
||||
await self._writer.drain()
|
||||
|
||||
self._writer.write(b"\r\n")
|
||||
await self._writer.drain()
|
||||
|
||||
logger.debug(
|
||||
(
|
||||
"{} request of type {} sent (sequence {}, session {})"
|
||||
", expecting {} responses from queue {}"
|
||||
).format(
|
||||
"Encrypted" if encrypt else "Plaintext",
|
||||
mimetype,
|
||||
sequence,
|
||||
session,
|
||||
self.window_size + 1,
|
||||
id(queue),
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
while True:
|
||||
coro = queue.get()
|
||||
if no_data_timeout is not None:
|
||||
try:
|
||||
resp: HttpMediaResponse = await asyncio.wait_for(
|
||||
coro, timeout=no_data_timeout
|
||||
)
|
||||
except asyncio.exceptions.TimeoutError:
|
||||
logger.debug(
|
||||
"Server did not send a new chunk in {} sec (sequence {}"
|
||||
", session {}), assuming the stream is over".format(
|
||||
no_data_timeout, sequence, session
|
||||
)
|
||||
)
|
||||
break
|
||||
else:
|
||||
# No timeout, the user needs to cancel this externally
|
||||
resp: HttpMediaResponse = await coro
|
||||
logger.debug("Got one response from queue {}".format(id(queue)))
|
||||
if resp.session is not None:
|
||||
session = resp.session
|
||||
if resp.encrypted and isinstance(resp.plaintext, Exception):
|
||||
raise resp.plaintext
|
||||
# print(resp.plaintext)
|
||||
yield resp
|
||||
|
||||
finally:
|
||||
# Ensure the queue is deleted even if the coroutine is canceled externally
|
||||
if session in self._sessions:
|
||||
del self._sessions[session]
|
||||
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||
await self.close()
|
||||
|
||||
async def close(self):
|
||||
if self._started:
|
||||
self._started = False
|
||||
self._response_handler_task.cancel()
|
||||
self._writer.close()
|
||||
await self._writer.wait_closed()
|
22
deps/lib/python3.10/site-packages/pytapo/media_stream/temp.py
vendored
Normal file
22
deps/lib/python3.10/site-packages/pytapo/media_stream/temp.py
vendored
Normal file
@ -0,0 +1,22 @@
|
||||
|
||||
"""
|
||||
if not self.sentAudio:
|
||||
self.sentAudio = True
|
||||
print("Read")
|
||||
with open("sample.mp2", mode="rb") as file: # b is important -> binary
|
||||
fileContent = file.read()
|
||||
data = fileContent
|
||||
headers = {}
|
||||
headers[b"Content-Type"] = str("audio/mp2t").encode()
|
||||
headers[b"X-Session-Id"] = str(session).encode()
|
||||
headers[b"Content-Length"] = str(len(data)).encode()
|
||||
|
||||
await self._send_http_request(b"--" + self.client_boundary, headers)
|
||||
chunk_size = 4096
|
||||
for i in range(0, len(data), chunk_size):
|
||||
print(data[i : i + chunk_size])
|
||||
self._writer.write(data[i : i + chunk_size])
|
||||
await self._writer.drain()
|
||||
|
||||
print("sending payload")
|
||||
"""
|
58
deps/lib/python3.10/site-packages/pytapo/temp.py
vendored
Normal file
58
deps/lib/python3.10/site-packages/pytapo/temp.py
vendored
Normal file
@ -0,0 +1,58 @@
|
||||
|
||||
def scan(self):
|
||||
requests = self.performRequest(
|
||||
{
|
||||
"method": "multipleRequest",
|
||||
"params": {
|
||||
"requests": [
|
||||
{
|
||||
"method": "getDeviceInfo",
|
||||
"params": {"device_info": {"name": ["basic_info"]}},
|
||||
}, # correct request, OK
|
||||
{
|
||||
"method": "getDeviceInfo",
|
||||
"params": {"device_infoBAD": {"name": ["basic_info"]}},
|
||||
}, # incorrect param key: -40106
|
||||
{
|
||||
"method": "getDeviceInfo",
|
||||
"params": {"device_info": {"name": ["basic_infoBAD"]}},
|
||||
}, # incorrect value in array: -40106
|
||||
{
|
||||
"method": "getDeviceInfo",
|
||||
"params": {"device_info": {"name": []}},
|
||||
}, # empty array: OK
|
||||
{
|
||||
"method": "getDeviceInfo",
|
||||
"params": {"device_info": {"name": "null"}},
|
||||
}, # incorrect value type: -40106
|
||||
{
|
||||
"method": "getDeviceInfoBAD",
|
||||
"params": {"device_info": {"name": []}},
|
||||
}, # incorrect function name: -40210
|
||||
{
|
||||
"method": "getDeviceInfo",
|
||||
"paramssssss": {"device_info": {"name": []}},
|
||||
}, # incorrect params key name, OK
|
||||
{"method": "getDeviceInfo"}, # only method, OK
|
||||
{"method": "getDeviceInfoBAD"}, # method does not exist: -40210
|
||||
{
|
||||
"method": "getConnectStatus"
|
||||
}, # -40210 this means the function does not exist
|
||||
{
|
||||
"method": "scanApList"
|
||||
}, # -40210 this means the function does not exist
|
||||
{
|
||||
"method": "connectAp"
|
||||
}, # -40210 this means the function does not exist
|
||||
{
|
||||
"method": "getConnectionType",
|
||||
"params": {"network": {"get_connection_type": []}},
|
||||
}, # todo: create wifi function
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
for request in requests["result"]["responses"]:
|
||||
print(request)
|
||||
|
||||
return True
|
Reference in New Issue
Block a user