This commit is contained in:
darthsandmann
2023-02-11 19:37:07 +01:00
parent 15ed948dcd
commit d1289421b9
84 changed files with 34044 additions and 2 deletions

View 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"
}

View 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

View 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

View 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

View 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"))

View 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")

View 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

View 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()

View 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")
"""

View 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